Compare commits

70 Commits
Author SHA1 Message Date
oqyude 16644fcc0b metaagent: install v3.0.0, migrate docs/arch/* → .agent/
- install.sh: .agent/src/ (PROTOCOLS, COMMANDS, TEMPLATES, install.sh/ps1, GUIDE)
- .temp/ добавлен в .gitignore
- .agent/checkpoints.json: phases.init=completed, project_type=existing
- .agent/rules/project-rules.md: R1-R5 (инварианты, ловушки, куда лезть, проверки)
- .agent/context/analysis-report.md: стек, архитектура, конвенции, кандидаты CI
- .agent/context/project-state.md: сжатый слепок (хосты, сервисы, ADR)
- .agent/roadmap/sources.md: открытые вопросы слоёв 0-11 (бывший invariants.md)
- .agent/tasks/manifest.{json,md}: 16 задач A1-F + 23 в backlog
- .agent/decisions/0001-sops-secrets-paths.md: ADR для инварианта S1
- AGENTS.md: metaagent-шапка + сжатая выжимка nixos (yaml frontmatter сохранён)
- удалены docs/arch/{map,invariants,todo}.md
2026-10-09 20:37:14 +03:00
oqyude 7c9aa24779 agents: yaml frontmatter for Obsidian dataview 2026-10-09 20:28:49 +03:00
oqyude a4984a6b37 sops secrets path changed 2026-10-09 19:26:31 +03:00
oqyude 02927a26bb vtimeline added and formatted 2026-10-09 16:59:45 +03:00
oqyude bc5aad87da opencode remapping 2026-10-09 16:38:16 +03:00
oqyude f3c50e90ba authelia in services 2026-10-09 16:37:03 +03:00
oqyude e7c0fde0b1 tty added and opencode fixed 2026-10-09 14:48:12 +03:00
oqyude 3c9c5100a8 authelia added 2026-10-08 15:30:05 +03:00
oqyude 75433e2af7 vhost connecting 2026-10-07 17:53:06 +03:00
oqyude 7fd1736f1d vtimeline arch 2026-10-07 17:52:54 +03:00
oqyude 58333d0257 open-webui via podman added 2026-10-07 13:49:40 +03:00
oqyude 5e9641602a opencode: dynamic HM symlink, env via xlib, document migration journal
Three small things together:

1. relinkHomeManager was pinning the versioned symlink name to
   'home-manager-24-link'. That breaks on every HM major-version bump:
   HM 25 will move the alias to 'home-manager-25-link' and the script
   will silently stop relinking. Derive the name from one hop of the
   stable 'home-manager' symlink, with a guard against the missing case
   so a fallback never accidentally rewrites the profiles/ directory
   itself.

2. programs.opencode.web.environmentFile now reads from
   xlib.dirs.opencode-server-env (added previously in users.nix + dirs.nix).
   The sops materialization and the systemd EnvironmentFile can no
   longer silently desync.

3. Document the oh-my-openagent 2026-07-opencode-config-unification
   migration trap that logs 'Migration backup path already exists' on
   every startup: the backup path embeds the content-hashed store path,
   which stays valid in /nix/store across HM activations, so the
   deterministic collision never resolves itself. Recovery is
   'rm -rf ~/.omo/migration-backup-*' to let omo retry; if it keeps
   failing on the same path the plugin version probably expects a new
   schema and this file needs changes.

Also trim the over-explained [Service] / serviceConfig comment: the
home-manager attrset-union behavior is general, not specific to this
unit, so the explanation got shorter without losing the invariant.
2026-10-07 11:38:16 +03:00
oqyude b2718fd1e7 xlib+users: centralize opencode server.env path
The path '/home/<user>/.config/opencode/server.env' was duplicated
between users.nix (sops materialization) and home/modules/opencode.nix
(programs.opencode.web.environmentFile). Drift between the two was a
silent auth-bypass vector: if one moved, the systemd unit would either
fail to find OPENCODE_SERVER_PASSWORD or skip EnvironmentFile entirely.

Single source in lib/xlib/dirs.nix; both call sites now read from it.
2026-10-07 11:36:55 +03:00
oqyude 534fa429e1 docs arch begin 2026-10-07 11:29:21 +03:00
oqyude 698a1afaf7 glow added 2026-10-06 15:17:15 +03:00
oqyude 14c91e68a4 todo removed 2026-10-05 15:38:16 +03:00
oqyude c73a698857 opencode fix linger 2026-10-04 22:27:55 +03:00
oqyude c8d4a12a73 3x-ui: revert nginx + ports to 543fcc6 (testing) declarative state
Sapphira: HTTP reverse proxy serves panel/sub on x.zeroq.su;
no xray stream on 443 and no 8443 stream either (8443 is directly
exposed by podman as 0.0.0.0:8443:8443/tcp).

Otreca: stream on 443 routes by SNI (panel via pubray1.zeroq.su,
xray default) and 8443 is direct 0.0.0.0:8443.

Modules/containers/3x-ui.nix:
  - basePorts restored: '0.0.0.0:8443:8443/tcp' (was '127.0.0.1:15380:8443/tcp')
  - realityPorts restored (was 'lib.optional ... "127.0.0.1:15380:443/tcp"')
  - image restored: ':latest' (was ':v3.9.0')

Modules/server/nginx.nix:
  - removed 8443 streamConfig for xray (the one b0191bc added)
  - removed 8443 from allowedTCPPorts

Other files (configurations/{server,vds,wsl}.nix, home/modules/opencode.nix)
left alone — they contain SSH firewall / builder / opencode web changes
unrelated to nginx + ports that the user asked to revert.
2026-10-04 22:05:27 +03:00
oqyude c854b2cc6d 3x-ui: drop dead -p 127.0.0.1:15380:443/tcp (double-bind blocks start)
The systemd unit on the otreca VDS carried two -p flags that bind
the same host port 127.0.0.1:15380:

  -p 127.0.0.1:15380:8443/tcp   # from basePorts
  -p 127.0.0.1:15380:443/tcp    # from realityPorts (when reality443Forwarding=true)

podman 5.x tries to bind 127.05 in each -p flag and the second
fails with EADDRINUSE, even though no process is visible in ss —
the bind happens at the proxy level before the container starts:

  Error: cannot listen on the TCP port: listen tcp4 127.0.0.1:15380:
  bind: address already in use

Symptom on otreca: podman-3xui_app.service hits start-limit-hit
after 5 rapid retries.

The 15380:443 mapping is dead code: the container's only Reality
inbound listens on 8443, and nginx stream already routes host:443
to 127.0.0.1:15380 via SNI (modules/server/nginx.nix streamConfig).
reality443Forwarding remains a host option for configurations to
declare intent; the broken port-mapping generation is replaced with
an empty list.
2026-10-04 21:37:14 +03:00
oqyude 22a19be1b6 3x-ui: rollback to c05cc88 (before otreca vds commit)
Revert the b0191bc 'otreca vds: pin 3x-ui:v3.8.5 + nginx stream + ssh
tailscale-only + patch-3xui-xray-config' changes:

- 3x-ui.nix: back to :latest image, direct 0.0.0.0:8443 port mapping,
  remove migrateScript + patchScript and their systemd units/timer.
- vds.nix: re-open 22/tcp on public (openFirewall = true); remove the
  tailscale0-only port rule.
- nginx.nix: drop the 8443 stream proxy.
- Remove modules/containers/3x-ui-migration-notes.md.

Reason: those changes, once applied on otreca, left the 3x-ui container
in a start-limit-hit loop (bind 127.0.0.1:15380: address already in use,
nothing visible in ss - probably a stale TIME_WAIT or slirp4netns port
from a prior container that never released).
2026-10-04 21:30:47 +03:00
oqyude 99747849d3 3x-ui regress 2026-10-04 21:03:48 +03:00
oqyude b88c8ebce0 remote building off 2026-10-04 18:34:39 +03:00
oqyude cc20ee637d opencode oom fixes 2026-10-04 17:41:32 +03:00
oqyude 95ba7c2903 Remove empty TODO.md (duplicate of todo.md on case-insensitive fs) 2026-10-04 04:58:55 +03:00
oqyude b0191bc7d1 otreca vds: pin 3x-ui:v3.8.5 + nginx stream + ssh tailscale-only + patch-3xui-xray-config 2026-10-04 04:47:33 +03:00
oqyude 543fcc61d9 testing 2026-10-03 23:39:21 +03:00
oqyude 0b9ac53b71 removed unne 2026-10-03 21:59:46 +03:00
oqyude b476cace8e kokoro-tts autostart disabled 2026-10-03 21:53:27 +03:00
oqyude 2de80a356b wsl ssh bridge 2026-10-03 21:51:33 +03:00
oqyude fb56f6310b opencode 2026-10-03 20:21:19 +03:00
oqyude 1c77ae658e kokoro-tts stream added 2026-10-03 15:20:33 +03:00
oqyude 509fd3dde0 kokoro-tts 2026-10-03 01:03:50 +03:00
oqyude 958247b22c soft coding 2026-10-02 23:05:00 +03:00
oqyude c05cc88843 restructuring 2026-10-01 15:14:37 +03:00
oqyude d49fd5a358 big refactoring 2026-10-01 14:16:17 +03:00
oqyude 417c7abda6 hide ports 2026-09-26 16:07:37 +03:00
oqyude ac561815ed 3x-ui fix 2026-09-24 22:49:52 +03:00
oqyudeandSisyphus 2be5b168ac tape-rotation fix
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-24 17:21:58 +03:00
oqyude eddf44fb02 tape-rotation res 2026-09-24 15:38:24 +03:00
oqyude caeb04142d onlyoffice regress 2026-09-23 23:17:39 +03:00
oqyude 1db2b0955b nextcloud fix 2026-09-23 22:23:13 +03:00
oqyude a8ddf9b8dc changes 2026-09-23 22:16:04 +03:00
oqyude bc3566d80e nextcloud35 2026-09-23 22:14:08 +03:00
oqyude 0fdf6c965c tape-rotation freezed 2026-09-23 22:05:13 +03:00
oqyude 5bccf7586d nix flake update 2026-09-23 22:03:40 +03:00
oqyude 2912581b99 tape rotation added 2026-09-23 21:58:39 +03:00
oqyude 72b4bdfbd8 glances fix 2026-09-22 18:31:00 +03:00
oqyude 44b85e1ebc cleaning 2026-09-22 18:05:10 +03:00
oqyude b57ca3eedf 3x-ui next 2026-09-16 16:09:51 +03:00
oqyude 309644eab2 fixes 2026-09-15 21:22:33 +03:00
oqyude ba5a2b378e nix flake update 2026-09-15 21:22:27 +03:00
oqyude 7441e7f98a 3x-ui regress 2026-09-15 00:54:31 +03:00
oqyude 99b5a8f1eb jray upd 2026-09-08 21:58:51 +03:00
oqyude 1c3a524b42 iperf3 added 2026-09-08 21:37:38 +03:00
oqyude be064aca66 nix flake update 2026-09-02 23:32:34 +03:00
oqyude 839b97d01a justray and usbtree 2026-09-02 17:08:18 +03:00
oqyude 482d32e1a6 microfix 2026-09-02 13:35:58 +03:00
oqyude e1d276097d refactoring 2026-08-29 04:05:38 +03:00
oqyude 7f5ea81f37 3x-ui: make module generic via xlib.services.3x-ui options
The 3x-ui container config was hardcoded for vds: it mounted the LE
cert for pubray1.zeroq.su and published host:15380→container:443 for
Xray REALITY. The server imports the same module but for x.zeroq.su
(no REALITY inbound, no cert needed by 3x-ui itself yet).

Add two options so each device picks what it needs:
  - xlib.services.3x-ui.certDomain: domain whose LE cert is mounted
    at /root/cert/{fullchain,key}.pem. null means no cert mount.
  - xlib.services.3x-ui.reality443Forwarding: when true, also publish
    host:15380→container:443 for nginx stream SNI-routed REALITY.

vds sets both. Server sets only certDomain (kept harmless; nginx
still terminates TLS for x.zeroq.su, so the mounted cert is unused
until/unless 3x-ui is reconfigured to terminate TLS itself).
2026-08-28 01:17:59 +03:00
oqyude 11af2c150a vds: drop pubrayx1.zeroq.su from SNI map — Xray now served under pubray1
All Xray REALITY clients already connect to VDS_IP via
pubray1.zeroq.su (or any of its subdomains). Removing the explicit
pubrayx1.zeroq.su → xray rule means the default route catches it.
This way we only have to publish one domain (pubray1.zeroq.su)
in subscriptions instead of two.

Companion change in x-ui.db (separate runbook step): subURI set
to https://pubray1.zeroq.su/subs/ so regenerated subscriptions
emit URLs under pubray1.zeroq.su, not x.zeroq.su.
2026-08-28 00:56:28 +03:00
oqyude 26e53e96bd vds: SNI-route TLS on 443 to Xray (15380→443) and 3x-ui panel (2049)
nginx stream + ssl_preread reads the ClientHello SNI and forwards the
raw TCP stream (no TLS termination) to either:
  - 3x-ui panel on 127.0.0.1:2049 (SNI=pubray1.zeroq.su)
  - Xray on 127.0.0.1:15380 (SNI=pubrayx1.zeroq.su or default)

podman maps host:15380 → container:443 so Xray inside sees the client
on port 443 (matching its REALITY config) even though the host-side
port from podman's perspective is 15380. Host:2049 still maps to
container:2049 — 3x-ui now terminates TLS itself using the Let's
Encrypt cert mounted from /var/lib/acme/pubray1.zeroq.su/.

x-ui.db: webCertFile, webKeyFile and webDomain set so the panel
answers HTTPS on 2049. nginx no longer owns a server block on 443 —
only an ACME-only vhost for cert renewal.

REALITY inbound on container:443 still needs to be created via the
panel UI (the xrayTemplateConfig doesn't have it yet). The host-side
and routing plumbing is ready for it.
2026-08-28 00:36:54 +03:00
oqyude 0c2b45ea6f Revert "vds/nginx: forward real client IP to 3x-ui"
This reverts commit 2cd636b6d4.
2026-08-28 00:12:14 +03:00
oqyude 2cd636b6d4 vds/nginx: forward real client IP to 3x-ui
With podman bridge networking, 3x-ui no longer sees the actual
client IP — it sees the bridge gateway. Without explicit
proxy_set_header directives, subscription URLs, geo-rules, logs
and fail2ban will all treat every request as coming from the same
IP.

Apply Host/X-Real-IP/X-Forwarded-For/X-Forwarded-Proto to all
3x-ui locations so the panel keeps working as if it were on
host network.
2026-08-27 23:28:41 +03:00
oqyude 2bc02c316d podman changes 2026-08-27 23:21:18 +03:00
oqyude 0bbb19b429 nix flake update 2026-08-24 01:33:25 +03:00
oqyude cbf731495a minecraft: use jdk25 for fabric 26.2 server 2026-08-12 00:22:53 +03:00
oqyude b933436a6e minecraft: use linkFarmFromDrvs for mods 2026-08-11 23:36:28 +03:00
oqyude 0585f234ba minecraft: add 26.2 mods (lithium/ferritecore/krypton) 2026-08-11 23:34:26 +03:00
oqyude 133db71db0 minecraft-server setup 2026-08-11 23:14:16 +03:00
oqyude 411c118500 br v4 2026-08-11 22:13:53 +03:00
125 changed files with 9119 additions and 1626 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"metaagent_version": "3.0.0",
"session_id": "metaagent-init-2026-10-09",
"target_repo": "S:/Git/nixos",
"goal": "Установить metaagent, перенести накопленные данные (AGENTS.md, docs/arch/*) в структуру .agent/.",
"project_type": "existing",
"phases": {
"init": "completed",
"analyse": "completed",
"roadmap": "completed",
"design": "skipped",
"decomposition": "completed",
"execution": "in_progress",
"metastate": "pending",
"handoff": "pending"
},
"tasks": [
{ "id": "T1", "title": "A1: mobile.nix импортирует несуществующий lib/xlib.nix", "status": "pending", "origin": "user:direct" },
{ "id": "T2", "title": "A2: убедиться, что nix flake check вообще запускается", "status": "pending", "origin": "user:direct" },
{ "id": "T3", "title": "A3: явная финальная политика nftables на VDS", "status": "pending", "origin": "user:direct" },
{ "id": "T4", "title": "B1: guard на несмонтированный носитель /mnt/services", "status": "pending", "origin": "user:direct" },
{ "id": "T5", "title": "B2: зафиксировать, что бэкапов в конфигурации нет", "status": "pending", "origin": "user:direct" },
{ "id": "T6", "title": "C1: вернуть расследование 3x-ui, потерянное при откате", "status": "pending", "origin": "user:direct" },
{ "id": "T7", "title": "C2: зафиксировать фактические версии панели и ядра 3x-ui", "status": "pending", "origin": "user:direct" },
{ "id": "T8", "title": "C3: убрать сервис автообновления 3x-ui", "status": "pending", "origin": "user:direct" },
{ "id": "T9", "title": "C4: записать, что ядро Xray — состояние панели, а не Nix", "status": "pending", "origin": "user:direct" },
{ "id": "T10", "title": "C5: решить судьбу reality443Forwarding", "status": "pending", "origin": "user:direct" },
{ "id": "T11", "title": "D1: пробросы роутера — главный недостающий инвариант", "status": "pending", "origin": "user:direct" },
{ "id": "T12", "title": "D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira", "status": "pending", "origin": "user:direct" },
{ "id": "T13", "title": "D3: убрать мёртвое правило firewall на sapphira", "status": "pending", "origin": "user:direct" },
{ "id": "T14", "title": "E1: написать AGENTS.md в корне (с metaagent-шапкой)", "status": "completed", "origin": "user:direct" },
{ "id": "T15", "title": "E2: выбрать проверки, которые заменят половину инвариантов", "status": "pending", "origin": "user:direct" },
{ "id": "T16", "title": "E3: судьба 15 закомментированных модулей", "status": "pending", "origin": "user:direct" }
],
"last_updated": "2026-10-09T20:30"
}
+119
View File
@@ -0,0 +1,119 @@
# Analysis Report
## Session
- **Session ID:** `metaagent-init-2026-10-09`
- **Target repo:** `S:/Git/nixos`
- **Date:** 2026-10-09
- **Project type:** `existing` (NixOS-конфиг, 120 `.nix`, ~8.5k строк)
## 1. Общая информация
- **README:** одна строка без смысла (`"I'm a super newbie who just posted my stuff here. Now maybe about intermediate"`); функциональную роль README играет `AGENTS.md` (корень).
- **Лицензия:** не указана.
- **CI/CD:** отсутствует. `nix flake check` — единственная автоматическая защита, прогоняется вручную. `deploy/default.nix:27-29` — `checks = builtins.mapAttrs (... deployChecks)`, но они покрывают только deploy-сценарий.
- **Точка входа:** `flake.nix` → `nixosConfigurations.<attr>` (хосты) + `nixOnDroidConfigurations.<attr>` (Android). Реестр хостов: `configurations/default.nix:13-39`.
- **Система сборки:** Nix + NixOS flakes. Home-manager, sops-nix, disko, deploy-rs, grub2-themes, justray.
## 2. Стек технологий (existing)
| Компонент | Значение |
|---|---|
| Язык | Nix (`.nix`) |
| Фреймворк | NixOS modules + home-manager |
| База данных | PostgreSQL (в `modules/server/postgresql.nix`), sqlite (3x-ui) |
| Тестовый раннер | отсутствует (см. §5) |
| Пакетный менеджер | Nix (`nix flake`, `nix-env`, `nix profile`) |
| Линтер/форматтер | отсутствует (комментарий-density 38/120 файлов без комментариев — открытый вопрос 0.2) |
## 3. Архитектура (existing)
```
flake.nix
├── configurations/ ← реестр хостов (1 запись = 1 машина)
│ ├── default.nix ← hosts + xlib + mkSystem
│ ├── <host>.nix ← модульное тело хоста
│ └── hardware/<host>.nix
├── home/ ← home-manager (per device-type)
├── modules/
│ ├── options.nix ← кросс-модульные опции
│ ├── default.nix ← defaultModule + strictModule (для nix-on-droid)
│ ├── essentials/ ← packages, services, settings, ssh, shell, systemd-routines
│ └── <type>/ ← per-type: desktop/, server/, vds/, wsl/, containers/, termux/, other/
├── lib/
│ ├── mkSystem.nix ← nixosSystem + specialArgs(xlib, inputs)
│ └── xlib/ ← чистые данные: devices, dirs, helpers
├── overlays/, pkgs/, deploy/, secrets/ (sops)
└── .sops.yaml ← один age-ключ на secrets/<name>.(yaml|json|env|ini)
```
**Паттерн:** модульный монолит с xlib-инъекцией (аналог dependency injection через `specialArgs`).
**Ключевые модули:**
| Модуль | Описание |
|---|---|
| `configurations/default.nix:13-39` | Реестр хостов (7 entries); `mkXlib` в строке 50 |
| `configurations/<host>.nix` | Per-host: имя, тип, импорт модулей; 7 файлов |
| `lib/xlib/default.nix:38-77` | `mkXlib` — единственная точка сборки xlib |
| `lib/xlib/device.nix:12-41` | Закрытое множество device.types: { minimal, primary, secondary, server, vds, wsl, termux } |
| `lib/xlib/helpers.nix` | `mkBindMount`, `mkSystemdBind`, `mkServiceStorage`, `mkNtfsMount`, `mkExfatMount`, `mkTmpDirs`, `mkSymlinks` |
| `modules/options.nix` | Кросс-модульные опции: `host.builder.*`, `host."3x-ui".*` |
| `modules/default.nix:9-39` | `nixosModules.default` — импортируется на каждый NixOS-хост; `nixosModules.strict:40-53` — для nix-on-droid |
| `modules/essentials/` | `packages`, `services`, `settings`, `ssh`, `shell`, `systemd-routines` |
| `modules/users.nix` | Пользователь `oqyude` (uid 1000, sapphira=1001), sops-секреты, hostKey bootstrap |
| `modules/server/` | 20+ системных сервисов sapphira (см. `server/default.nix:imports`) |
| `modules/server/systemd.nix` | rsync oneshots с `requiresMountsFor` guard (единственный пример guard'а) |
| `modules/server/{nginx,coredns}.nix` | Reverse proxy + DNS для зон `zeroq.su` и `home.arpa` |
| `modules/containers/` | podman-контейнеры: 3x-ui (заморожен), tape-rotation, remnanode, kokoro-tts, openhands, remnawave-examples |
| `home/<type>.nix` | Home-manager per device-type; для `root` — без профиля |
| `home/modules/opencode.nix` | OpenCode CLI + systemd user services (см. R2 — `Service` vs `serviceConfig`) |
| `deploy/default.nix` | deploy-rs ноды: sapphira, otreca, rydiwo (НЕ atoridu/wsl/epral) |
## 4. Конвенции (existing)
- **Стиль:** Nix-форматирование, разные отступы в разных файлах (нет единого стандарта).
- **Импорты:** `imports = [ ./foo.nix ./bar.nix ];` или list-spread. `lib.optional` для условных импортов.
- **Типизация:** types из `lib.types` (например, `lib.types.str`, `lib.types.bool`, `lib.types.attrsOf`).
- **Обработка ошибок:** `throw` + literal-сообщения (например, `lib/xlib/device.nix:12-41` — throw со списком валидных типов).
- **Логирование:** не формализовано; rsync oneshots в `modules/server/systemd.nix` — единственный пример с `-v` + structured output.
## 5. Тесты (existing)
- **Команда запуска:** отсутствует. Единственная полуавтоматическая проверка — `nix flake check`.
- **Всего тестов:** 0
- **Пройдено:** N/A
- **Упало:** N/A
- **Пропущено:** N/A
- **Упавшие тесты:** N/A
Кандидаты на CI-проверки (открытый вопрос 11.2):
1. ни одного `:latest` в образах (grep по `image =`)
2. `nix flake check` зелёный — уже ловит A1
3. домены в `coredns.nix` ↔ vhost'ы в `nginx.nix` совпадают в обе стороны
4. для каждого потребителя `mkServiceStorage` каталог существует на `External`
5. последнее правило самописной nftables-цепочки явное
6. `listen.addr` — адрес интерфейса, а не сеть
7. все файлы в `secrets/` матчат `path_regex` из `.sops.yaml`
## 6. Базовая проверка (existing)
- **Сборка:** не проверена в этой сессии (нет `nix` в PATH, AGENTS.md ссылается на `nix flake check`).
- **Запуск:** N/A (NixOS-конфиг, не приложение).
- **Git status:** рабочее дерево было чистым после коммита `7c9aa24` (yaml frontmatter в AGENTS.md). 1 modified файл (AGENTS.md) — fixed.
## 7. Требования (N/A для existing)
## 8. Примечания
- **Проход по репозиторию 2026-10-05**: 120 `.nix`, ~8.5k строк. Подтверждённые
инварианты (1–6) зафиксированы в `.agent/rules/project-rules.md` (R1).
- **Слои 9–11** (home-manager, deploy, формат) перенесены в `.agent/roadmap/sources.md`
как открытые вопросы; см. §9.2, §10.1-10.4, §11.1-11.2 исходного `invariants.md`.
- **Шаблон инварианта** (4 оси: Утверждение, Где, Почему, Действие) — для новых
записей. Обратное: до правки `authelia.nix:51,108,109` шёл через хелпер
`sopsPath = name: "/run/secrets/${name}"`; `open-webui.nix:95`, `tape-rotation.nix:61`,
`remnawave.nix:61, 129, 136` — литеральный хардкод. См. ADR-0001.
- **Bootstrap-цикл ключа** (R3) — должен быть задокументирован, иначе при
переустановке хоста агент не выведет. Открытый вопрос 4.2.
+107
View File
@@ -0,0 +1,107 @@
# Project State
# Auto-generated — updated by ANALYSE (initial) and METASTATE (on updates)
**Last updated:** 2026-10-09T20:30
**Session:** metaagent-init-2026-10-09
## Project Type
`existing` — NixOS-конфиг домашнего флота. 7 host-outputs (6 NixOS + 1 nix-on-droid).
## Tech Stack
| Category | Technology |
|----------|-----------|
| Language | Nix |
| Framework | NixOS modules + home-manager |
| Database | PostgreSQL (sapphira), sqlite (3x-ui) |
| Test runner | none — `nix flake check` is the only guard |
| Package manager | Nix (flakes) |
| Linter/formatter | none |
## Current Architecture
Модульный монолит с xlib-инъекцией. `flake.nix` → `nixosConfigurations.<attr>` через `configurations/default.nix`. Каждый хост получает `xlib` (identity + dirs + helpers) через `specialArgs` в `lib/mkSystem.nix`. `modules/default.nix` импортирует `nixosModules.default` (все NixOS) или `nixosModules.strict` (только nix-on-droid).
```
configurations/ → 7 хостов (default, atoridu, rydiwo, otreca, sapphira, wsl, epral)
modules/ → essentials + per-type (desktop/server/vds/wsl/containers/termux)
home/ → home-manager per device-type
lib/xlib/ → чистые данные (devices, dirs, helpers)
deploy/ → deploy-rs ноды: sapphira, otreca, rydiwo
secrets/ → sops, один age-ключ
```
## Key Modules
| Module | Status | Description |
|--------|--------|-------------|
| `configurations/default.nix` | existing | Реестр хостов + `mkXlib` |
| `lib/xlib/default.nix` | existing | `mkXlib` — единственная точка сборки xlib |
| `modules/default.nix` | existing | `defaultModule` (NixOS) + `strictModule` (nix-on-droid) |
| `modules/options.nix` | existing | Кросс-модульные опции |
| `modules/users.nix` | existing | Пользователь + sops + hostKey bootstrap |
| `modules/essentials/ssh.nix` | existing | openssh + hostKey |
| `modules/server/{nginx,coredns}.nix` | existing | Reverse proxy + DNS (`zeroq.su`, `home.arpa`) |
| `modules/server/postgresql.nix` | existing | PostgreSQL + `mkServiceStorage` (нужен B1 guard) |
| `modules/server/systemd.nix` | existing | rsync oneshots с `requiresMountsFor` |
| `modules/containers/3x-ui.nix` | frozen | Панель :latest, ядро Xray 26.7.x |
| `home/<type>.nix` | existing | Per-type home-manager |
| `home/modules/opencode.nix` | existing | OpenCode CLI + systemd user (см. R2) |
| `deploy/default.nix` | existing | deploy-rs: sapphira, otreca, rydiwo |
## Hosts
| Attr | hostname | device.type | deploy | stateVersion | Примечание |
|---|---|---|---|---|---|
| `default` | nixos | minimal | — | — | Шаблон |
| `atoridu` | atoridu | primary | — (manual) | 26.05 | xanmod, mini-PC, без nixos-hardware |
| `rydiwo` | rydiwo | secondary | deploy-rs | 26.05 | Chuwi MiniBook, NTFS `lamet-drive` (mask=0000) |
| `otrecа` | otreca | vds | deploy-rs | 25.05 | VPS, Tailscale-only SSH, nftables (нужен A3) |
| `sapphira` | sapphira | server | deploy-rs | 25.05 | Домашний сервер, `firewall.enable = false` намеренно |
| `wsl` | wsl | wsl | — (manual) | 24.11 | WSL на vetymae (Windows 192.168.1.100) |
| `epral` | epral | termux | — | 24.05 | Android nix-on-droid, через `mobile.nix` |
## Services (sapphira, активные)
20+ системных сервисов + 6 контейнеров. Полный инвентарь в `tasks/manifest.json` (задачи A1-F). Все, использующие `mkServiceStorage`, нуждаются в B1 guard: postgresql, samba, homebox, gitea, navidrome, syncthing, uptime-kuma, immich, nextcloud, calibre-web, 3x-ui, tape-rotation.
## Network
```
LAN 192.168.1.0/24:
.20 sapphira (зашит в ~30 мест; см. вопрос 6.6)
.1 роутер (gateway)
.100 vetymae (Windows-хост с WSL)
Tailscale CGNAT 100.64.0.0/10:
100.64.0.0 = sapphira (назначен вручную, в 4 файлах)
100.86.62.4 = opencode на vetymae
100.106.21.39 = miniflux
Internet:
sapphira: пробросы роутера = 22, 80, 443, 8443 (xray), 22000 (syncthing)
```
## Decisions in Effect
| ADR | Decision | Status |
|-----|----------|--------|
| ADR-0001 | sops-пути: `config.sops.secrets.<name>.path` (не литерал) | active |
Подробнее: `.agent/decisions/0001-sops-secrets-paths.md`.
## Testing Status
Тестов нет. Единственная защита — `nix flake check`. Кандидаты на CI-проверки (7 штук) — в `analysis-report.md §5`.
## Open Concerns
- **B1**: нет guard'а на несмонтированный `/mnt/services` — сервисы стартуют на пустой БД.
- **A3**: nftables на VDS без явной финальной политики — неявный accept.
- **C1–C5**: 3x-ui заморожен, но без формального ADR; `podman.autoPrune` + `:latest` = деградация без коммита.
- **2.2/5.5**: `vetymae` / `lamet` / `therima` / `soptur` в `dirs.nix` — природа неясна.
- **4.1/4.2**: root-SSH и bootstrap-цикл ключа — не задокументированы.
- **6.6**: `192.168.1.20` зашит в 30 мест — рефакторинг отложен.
Полный список — в `roadmap/sources.md` (открытые вопросы слоёв 0-11).
@@ -0,0 +1,83 @@
# ADR-0001: sops-пути — только через `config.sops.secrets.<name>.path`
**Статус:** accepted
**Дата:** 2026-10-09
**Контекст:**
sops-nix материализует секреты на `/run/secrets/<attr>` по умолчанию.
Атрибут sops-блока (`config.sops.secrets.<attr>`) — единственный источник
истины для on-disk пути. Любой `path =` override на sops-блоке плюс хардкод
`"/run/secrets/<attr>"` в потребителе делает потребителя **молча**
сломанным: `nixos-rebuild` проходит, сервис стартует, файл читается — но
контент от прошлой версии или пустой. Симптом приходит из рантайма, не из CI.
До этой правки (см. «Обратное») в кодовой базе были хардкоды путей в 5
местах: `authelia.nix`, `open-webui.nix`, `tape-rotation.nix`, `remnawave.nix`.
В `remnawave.nix` тот же риск был двойной: путь хардкожен и в генераторе,
и в контейнере → расхождение двух копий = silent breakage.
**Рассматриваемые альтернативы:**
1. **A. `${config.sops.secrets.<attr>.path}`** — единая точка истины. Любой
`path =` override автоматически подхватывается потребителем.
2. **B. Хелпер `sopsPath = name: "/run/secrets/${name}"`** — был в `authelia.nix:51`
до правки. Компактнее в написании, но тащит хардкод `/run/secrets/` в API
и делает невозможным `path =` override без правки потребителя.
3. **C. `let envFile = "/run/secrets/${name}"; in { … }` для композитных
случаев** — для `remnawave.nix`, где один и тот же env-файл читается и
генератором, и контейнером. Используется, но с явным комментарием.
**Решение:** выбран вариант **A** для прямого доступа к одному секрету и
вариант **C** для композитных env-файлов в `remnawave.nix` (с комментарием).
**Обоснование:**
- **Единая точка истины.** Атрибут sops-блока — единственное место, где
определяется on-disk путь. Потребитель ссылается на `${config.sops.secrets.<attr>.path}`.
- **Симметрия с `mkUserSecret`.** `users.nix:33-41` уже использует этот
паттерн (`config.sops.secrets.<name>.path`) — единый стиль по репо.
- **Без хелпера.** `sopsPath = name: "/run/secrets/${name}"` выглядит
компактнее, но скрывает хардкод `/run/secrets/`. Когда кто-то добавит
`path = "/var/lib/..."` в sops-блок, потребитель через хелпер молча
сломается.
- **Двухкопийный env-файл в remnawave.nix** — композитный случай, где
`let`-биндинг в одном scope с комментарием «не дублировать литерал»
делает связь явной.
**Последствия:**
- Позитивные:
- `nix flake check` (после T1, T2) ловит несоответствие путей в compile-time.
- `path =` override в sops-блоке не ломает потребителя молча.
- Единый стиль по репо: 5 мест исправлены, новые пишутся по образцу.
- Негативные:
- Длиннее в написании, чем `"/run/secrets/${name}"`.
- Риски:
- Если кто-то добавит нового потребителя sops и напишет литерал
`/run/secrets/<name>` — молчаливое расхождение. Защита: код-ревью +
кандидат в CI-проверки (`secrets/missing-paths.nix` или grep).
**Invariant:**
> Любой потребитель sops-секрета в `modules/` ссылается на путь через
> `${config.sops.secrets.<attr>.path}`, а не через литерал
> `"/run/secrets/<attr>"`. Атрибут sops-блока — единственный источник
> истины для on-disk пути.
Зафиксировано в `.agent/rules/project-rules.md` (R1.7) + `analysis-report.md §8`.
**Обратное (где было сломано до этой правки):**
- `modules/server/authelia.nix:51,108,109` — через хелпер `sopsPath = name: "/run/secrets/${name}"`.
- `modules/containers/open-webui.nix:95` — литеральный хардкод.
- `modules/containers/tape-rotation.nix:61` — литеральный хардкод.
- `modules/containers/remnawave.nix:61, 129, 136` — литеральный хардкод, в двух местах (генератор + контейнер).
**Затронутые файлы (после правки):**
- `modules/server/authelia.nix:107-108`
- `modules/containers/open-webui.nix:101`
- `modules/containers/tape-rotation.nix:63`
- `modules/containers/remnawave.nix:16, 70, 138, 145` — `let`-биндинг для композитного env-файла (вариант C).
+14
View File
@@ -0,0 +1,14 @@
{
"version": "3.0.0",
"updated_at": "2026-10-09T20:30",
"decisions": [
{
"id": "0001",
"title": "sops-пути — только через config.sops.secrets.<name>.path",
"status": "accepted",
"date": "2026-10-09",
"file": ".agent/decisions/0001-sops-secrets-paths.md",
"tags": ["sops", "secrets", "security", "invariant"]
}
]
}
+161
View File
@@ -0,0 +1,161 @@
# Roadmap Sources
Источники задач для фазы DECOMPOSITION. Собрано при проходе по репозиторию
2026-10-05, ответы владельца учтены. Полный Q&A-источник (с разделами «Вопрос»,
«Факт», «Риск», «Кандидат») восстановим из git-истории:
`git log -p docs/arch/invariants.md | less` (последний коммит, где Q&A был полным).
Пометки: `[!]` — найденный дефект, не вопрос. `[?]` — не смог определить по коду.
`[✓]` — отвечено владельцем 2026-10-05.
## Статус ответов (2026-10-05)
Отвечено: **2.1, 5.2, 6.1, 6.3, 6.5, 7.1, 7.2** (7 пунктов). Остальные ждут
ответа (таблица ниже). Ключевое из ответов:
- **6.5** — `100.64.0.0` не «сетевой адрес вместо интерфейса»: Tailscale-адрес
sapphira, назначен вручную. См. ADR R1.4.
- **6.3** — `firewall.enable = false` на sapphira не недосмотр: граница на
роутере (5 портов). См. ADR R1.3 + задачу D1.
- **7.2** — 3x-ui рабочий, откат осознанный. Состояние «заморожено», не сломано.
См. ADR R1.5 + задачи C1–C5.
- **5.2** — подтверждённая дыра в защите данных → задача B1.
## Сводка подтверждённых инвариантов
См. `.agent/rules/project-rules.md` (R1.1–R1.7) — закреплено в `AGENTS.md` §«Подтверждённые инварианты» до мерджа.
| # | Пункт | Краткая формулировка | Задача |
|---|---|---|---|
| 1 | Все `outputs` флейка вычисляются | A1: правка `lib/xlib.nix` → `lib/xlib`; закрепить через `nix flake check` | A1 |
| 2 | External-диск монтируется до сервисов | mkServiceStorage + bind без guard'а → сервис стартует на пустой БД | B1 |
| 3 | Сетевая граница sapphira = роутер | 5 портов: 22, 80, 443, 8443, 22000; `firewall.enable = false` намеренно | D1 |
| 4 | `100.64.0.0` = Tailscale sapphira | Назначен вручную; в 4 файлах | D2 |
| 5 | 3x-ui заморожен | Панель на latest; ядро Xray на 26.7.x; миграция 26.9 провалена | C1–C5 |
| 6 | nftables на VDS — явная финальная политика | Сейчас ruleset без финального правила + конфликт с `firewall.*` | A3 |
| 7 | sops-пути — через `config.sops.secrets.<name>.path` | Любой `path =` override на sops-блоке делает хардкод-потребителя молча сломанным | ADR-0001 |
## Открытые вопросы (слои 0–8)
Самые важные — выделены. Источник для новых задач в `.agent/tasks/manifest.json`.
| ID | Вопрос | Что блокирует |
|---|---|---|
| 0.1 | Восстанавливать ли migration notes, удалённые в `22a19be`? | C1: реконструкция заметки 3x-ui |
| 0.2 | Комментарий-density 38/120 файлов без комментариев — нормально? | Стиль модулей |
| 0.3 | 15 закомментированных модулей: удалить или хранить как референс? | E3: чистота кода |
| 0.4 | README пустой, todo.md нет — норма? | E1: AGENTS.md/README |
| 1.3 | Лишние inputs в flake (`justray`, `nix-minecraft`, `proxy-suite`)? | Чистота flake |
| 1.5 | `nix-systems` через `follows` — оптимизация размера lock | Документация |
| **2.2** | **`vetymae` / `lamet` / `therima` / `soptur` — те же машины или хосты вне реестра?** | **DNS/nginx/identity** |
| 2.3 | sapphira uid=1001: блокер ли использование `/mnt/archive`/`/mnt/mobile`? | Миграция ФС |
| **2.5** | **stateVersion 24.05 / 24.11 / 25.05 / 26.05 — намеренный дрейф?** | **Миграции** |
| 2.6 | Есть ли escape hatch для per-host отличий в xlib? | Архитектура |
| 2.7 | `devices.termux` без NixOS-хоста — закрытый список | Документация |
| 3.2 | `any.nix` (minimal) нужны home-manager + sops + disko? | Минималка |
| **4.1** | **Как root получает доступ по SSH — authorizedKeys в коде нет** | **deploy, безопасность** |
| **4.2** | **Как разрешается цикл «ключ в секрете, а нужен для расшифровки»?** | **bootstrap, recovery** |
| 4.3 | Все файлы в `secrets/` покрыты `path_regex`? | sops |
| 4.4 | Как подключается вторая машина / второй человек при одном age-ключе? | sops, scale |
| 4.5 | `users.nix:87` — личный ключ или общий «ключ от деплоя»? | Безопасность |
| 5.1 | `/mnt/services` mode 0777 — осознанно? | Безопасность |
| 5.3 | NFS выключен, Samba работает — миграция? | Сетевые сервисы |
| 5.4 | NTFS-том `lamet-drive` `mask=0000` — что на нём? | Семантика |
| 5.5 | `therima` / `vetymae` / `soptur` в dirs.nix — реально смонтированы? | Семантика |
| 5.6 | Где бэкапы БД и 3x-ui? | B2 |
| 6.6 | `192.168.1.20` зашит в 30 мест — константа? | Рефакторинг |
| 6.7 | DNS ↔ сервисы — как ловим рассинхрон? | Документация, CI |
| **6.8** | **Публичные IP + SSH-алиасы в `home/termux.nix` — карта «хост → адреса» нужна?** | **Архитектура** |
| 6.9 | Какой путь REALITY правильный сейчас? | C5 |
| 7.4 | Почему не публиковать весь диапазон 14380-15379? | 3x-ui |
| 8.2 | `lamet.opencodes` → `:6061` (порт miniflux) — ошибка? | nginx |
| 8.4 | `onlyoffice` после трёх регрессов — работает? | Статус сервиса |
| 8.5 | Что слушает `:3002` (`/whiteboard` nextcloud)? | Карта сервисов |
| 8.6 | Бэкапы вне Nix — записать | Документация |
## Слой 9. home-manager
**9.1** `home/home.nix:52-57` — для пользователя импортируется
`home/${xlib.device.type}.nix`; для `root` — без профиля (строка 51).
**Вопрос:** почему у `root` нет home-профиля — сознательно?
**Кандидат:** `home/<type>.nix` = единственный источник «что есть на этом хосте»
для пользователя; добавление пакета в новый тип = правильный файл, а не
`home/default.nix`.
**9.2 [!]** `home/home.nix:28-43` для headless-хостов: `xdg.userDirs.* = null` и
`createDirectories = false`, при этом `lib/xlib/dirs.nix:26` обещает
`music-library = "${user-home}/Music"`.
**Вопрос:** кто создаёт `~/Music` и `~/Storage`? `createDirectories = false`
означает, что home-manager их не создаст, а `dirs.nix` на них ссылается.
**Риск:** на headless-хосте путь в конфиге есть, а каталога нет → тихий сбой
сервиса, который туда пишет.
**9.3 [!]** `home/modules/opencode.nix:339-350` (`c73a698`): в home-manager нельзя
писать `serviceConfig = { ... }` — рендерится литеральная секция `[serviceConfig]`,
которую systemd молча игнорирует. Закреплено в R2.
**9.4** `linger = true` добавлен ради `opencode-web` (`users.nix:71-75`) и включён
**для всех** хостов.
**Кандидат:** «user-сервисы переживают logout на всех хостах» — закрепить, потому
что это неочевидное поведение, влияющее на ресурсы и на безопасность.
**9.5** `home/modules/opencode.nix:286-303` — `opencode.web` слушает `0.0.0.0:4096`
(комментарий: nginx проксирует `127.0.0.1:4096`), и nginx на sapphira ходит туда
же по Tailscale у двух других хостов (см. 8.3).
**Кандидат:** `0.0.0.0` в `opencode.web` — обязательное условие для внешнего
доступа через `opencodes.*`; пароль приходит из sops-секрета `opencode_server`.
**9.6** Секреты opencode приходят в `~/.config/opencode/server.env` (dotenv),
`~/.local/share/opencode/auth.json` и `account.json` (json, `key = ""`).
**Кандидат:** эти три файла перезаписываются sops при каждой активации — ручные
правки в них теряются.
## Слой 10. deploy и проверка
**10.1** `deploy/default.nix:20-24` — цели: `sapphira` (server), `otrecа` (vds),
`rydiwo` (ноутбук). **Нет** `atoridu` (основной десктоп), `wsl`, `epral`.
**Вопрос:** почему не деплоится десктоп? И безопасно ли пересобирать ноутбук
`rydiwo` по SSH (он может быть выключен/на другом Wi-Fi)?
**Кандидат:** `deploy-rs` = только серверы + ноутбук; десктоп и WSL обновляются
вручную.
**10.2** `deploy/default.nix:18-19` — `sshUser = "oqyude"`, `user = "root"`.
См. 4.1: root-доход по SSH не описан в конфигурации.
**10.3** `deploy/default.nix:27-29` — `checks = builtins.mapAttrs (... deployChecks)`.
**Вопрос:** `nix flake check` реально проходит сейчас?
**10.4** CI нет, `flake check` не запускается автоматически.
**Кандидат:** минимальный локальный набор перед коммитом:
`nix flake check && nix build .#nixosConfigurations.<хост>.config.system.build.toplevel --dry-run`.
## Слой 11. Формат
**11.1** Где будет жить итог: `AGENTS.md` в корне (читается агентом всегда),
`docs/arch/map.md` (карта хостов/сервисов), `docs/arch/invariants.md` (этот файл).
**Кандидат:** этот файл после ответов превращается в `.agent/roadmap/sources.md`
с колонкой «ответ» и становится источником для `.agent/context/project-state.md`;
`.agent/context/project-state.md` — сжатая выжимка, без подробностей. (Сделано.)
**11.2** Какие инварианты можно превратить в автоматическую проверку (тогда они
перестанут «забываться»): 7 кандидатов в `analysis-report.md §5`.
**Вопрос:** какие из этих проверок ты хочешь, а какие — лишний CI?
## Шаблон инварианта
Этот шаблон — для добавления новых инвариантов в `.agent/rules/project-rules.md`
(и для зеркалирования в `AGENTS.md`). Та же 4-осевая структура используется,
чтобы вытащить «невидимое знание владельца» из существующего кода в явное
утверждение.
1. **Утверждение** — что именно верно и нельзя менять без осознанного
решения. Один-два абзаца, никаких «может быть».
2. **Где** — конкретные файлы и строки. Агент не должен угадывать.
3. **Почему** — что происходит при нарушении. Лучше всего — сценарий
(rebuild / рантайм), а не абстрактный риск.
4. **Действие** — task id в `manifest.json`, ссылка на коммит, или явное
«закреплено автоматической проверкой (см. analysis-report.md §5)».
Дополнительные поля по необходимости: «ловушка» (выглядит сломанным,
намеренно), «обратное» (где это уже было сломано раньше), «как проверить»
(grep / CI).
+160
View File
@@ -0,0 +1,160 @@
# Project Rules
Правила, которым агент обязан следовать во всех фазах. Источник: старый
`AGENTS.md` (накоплен при проходе по репозиторию 2026-10-05, ответы владельца
учтены 2026-10-05). Дополнения и уточнения — через `/adr`.
## Обязательные правила
### R1. Не ломать подтверждённые инварианты
1. **Все `outputs` флейка должны вычисляться.** `configurations/mobile.nix:12`
импортировал несуществующий `lib/xlib.nix` — был сломан, `epral` не
собирался. Закреплено через `nix flake check`.
2. **Носитель данных (`/home/oqyude/External`) обязан быть смонтирован** до
старта `postgresql`, `n8n`, `samba`, `homebox`, `minecraft`, `3x-ui`,
`tape-rotation`. `mkServiceStorage` даёт `bind,x-systemd.automount,nofail`
— без guard'а сервис стартует на пустой БД. Задача `B1` в `manifest.json`.
3. **Сетевая граница sapphira — роутер.** `firewall.enable = false` намеренно.
Роутер пробрасывает ровно 5 портов: **443, 80, 22000 (syncthing),
8443 (xray), 22 (ssh)**. `nginx.nix:225` (`allowedTCPPorts = [80 443]`) мёртв.
`openFirewall`/`allowedTCPPorts` на sapphira не имеют эффекта.
4. **`100.64.0.0` = Tailscale-адрес sapphira**, назначен вручную. Не сеть, не
ошибка. Используется в `nginx.nix`, `nextcloud.nix` (`trusted_proxies`),
`vds/systemd.nix`, `vds/nginx.nix`. При смене — править 4 файла.
5. **3x-ui заморожен.** Панель на последней версии (образ `:latest`),
ядро Xray на 26.7.x. Миграция на 26.9.x провалена. Обходные скрипты
(timer, migrateScript) отключены осознанно. **Не** обновлять ядро через
панель без записи в `decisions/` или `notes/`.
6. **nftables на VDS требует явной финальной политики.** Текущий ruleset
(`vds.nix:73-91`) — без явного последнего правила и без `policy` → неявный
accept. На otreca одновременно `nftables.enable = true` и `firewall.*` —
проверить, кто реально владеет ruleset'ом, перед правкой.
7. **sops-пути — через `config.sops.secrets.<name>.path`.** Любой
`path =` override на sops-блоке делает хардкод-потребителя молча
сломанным: rebuild зелёный, сервис стартует, контент пустой. См. ADR-0001.
### R2. home-manager `Service` ≠ `serviceConfig`
`home/modules/opencode.nix:339-350` (`c73a698`): в home-manager нельзя писать
`serviceConfig = { ... }` — рендерится литеральная секция `[serviceConfig]`,
которую systemd молча игнорирует («Unknown section 'serviceConfig'. Ignoring.»).
Правильно: `systemd.user.services.opencode-web.Service = { ... }`. В home-manager
cgroup-опции (`MemoryHigh`, `OOMScoreAdjust`, …) пишутся в
`systemd.user.services.<name>.Service`, **не** в `serviceConfig`. Ошибка
не диагностируется — она просто не применяется.
### R3. Sops-цикл ключа задокументировать
`/etc/ssh/id_ed25519` одновременно: `hostKeys` для sshd, `sops.age.sshKeyPaths`
для расшифровки, цель `ssh_key_private_known`, цель `ssh_key_public_host`.
Как разворачивается на чистой машине — **одноразовый bootstrap**. Должен быть
задокументирован, иначе при переустановке хоста агент не выведет.
### R4. Перед деплоем External-диска — `findmnt`
Перед рестартом сервисов, использующих `mkServiceStorage` (postgresql, samba,
homebox, gitea, navidrome, syncthing, uptime-kuma, immich, nextcloud,
calibre-web, 3x-ui, tape-rotation):
```bash
findmnt /home/oqyude/External
findmnt /mnt/services
```
До реализации guard'а (задача B1) — это единственная защита от старта на
пустой БД.
### R5. Проверка целостности sops-секретов
Любая правка `users.nix` или потребителя sops-секрета требует:
```bash
sops --version
nix build .#nixosConfigurations.<хост>.config.system.build.toplevel --dry-run
```
Расшифровка sops-секретов зависит от `/etc/ssh/id_ed25519` (см. R3).
Циклическая зависимость — см. «Где НЕ лезть без ответа».
## Ловушки (выглядит сломанным, намеренно)
Прежде чем чинить — проверить этот список. Здесь лежат решения, которые
иначе «поправляются» обратно и ломают рабочую систему.
| Где | Что выглядит ошибкой | На самом деле |
|---|---|---|
| `server.nix:130` | `firewall.enable = false` при 20 сервисах на `0.0.0.0` | Роутер фильтрует, см. R1.3 |
| `mobile.nix:95`, `wsl.nix:59` | `stateVersion` 24.05 / 24.11 vs 26.05 | Каждый хост зафиксирован на своей версии |
| `users.nix:66` | `uid = if hostname == "sapphira" then 1001 else …` | Костыль под 1000 = удалённый `yuyus`; удалять только после миграции ФС |
| `3x-ui.nix:54` | `image = …:latest` | Панель намеренно latest; ядро Xray — на 26.7.x |
| `3x-ui.nix:33-35` | `reality443Forwarding = true` на VDS | Следствие отката `c8d4a12`; смысл утрачен, см. задачу C5 |
| `server/default.nix:33-47` | 15 закомментированных модулей | Отключены осознанно, см. задачу E3 |
| `opencode.nix:339` | `systemd.user.services.opencode-web.Service` | `serviceConfig` рендерится в секцию `[serviceConfig]`, systemd молча игнорирует (`c73a698`); см. R2 |
| `vds.nix:73-91` | nftables без финального правила | Известный пробел, см. задачу A3 |
| `100.64.0.0` | Первый адрес CGNAT `/10` | Tailscale-адрес sapphira, см. R1.4 |
| `server.nix:61-63` | `z /mnt/services 0777` | World-writable точка монтирования; см. задачу B1 |
## Куда лезть по задаче
| Задача | Файл |
|---|---|
| Добавить хост | `configurations/default.nix` + `configurations/<host>.nix` + `configurations/{hardware,disko}/<host>.nix` |
| Добавить системный сервис | `modules/server/<name>.nix`, добавить в `modules/server/default.nix:imports` |
| Добавить home-пакет для пользователя | `home/<device_type>.nix` (через `lib.mkIf` или просто список) |
| Добавить опцию, читаемую несколькими модулями | `modules/options.nix` |
| Изменить mount/имя пользователя | `lib/xlib/dirs.nix`, `lib/xlib/device.nix` |
| Изменить домен / сертификат | `modules/server/coredns.nix` + `modules/server/nginx.nix` (или `vds/`) |
| Sops-секрет | положить в `secrets/<name>.<yaml\|json\|env\|ini>`; `users.nix:99` уже подключает `secrets/default.yaml`; dotenv/json-секреты — через `mkUserSecret` |
## Где НЕ лезть без ответа владельца
- `secrets/` (sops-encrypted, расшифровываются `/etc/ssh/id_ed25519` → циклический bootstrap).
- `let deploy` без проверки deploy-rs нод: `rydiwo` (ноутбук, может быть выключен).
- Любая правка, противоречащая «Подтверждённым инвариантам» выше (R1).
- `vetymae` / `lamet` / `therima` / `soptur` в `dirs.nix` — природа неясна (открытый вопрос 2.2/5.5).
- `192.168.1.20` в 30 местах — менять только при готовности править все места (открытый вопрос 6.6).
- Ядро Xray 26.7.x → 26.9.x — миграция провалена, не повторять без отдельной задачи.
## Проверки
```bash
# все outputs вычисляются
nix flake check
# правки применились на целевой хост
nix build .#nixosConfigurations.<host>.config.system.build.toplevel
# nixOnDroid
nix build .#nixOnDroidConfigurations.epral.config.system.build.toplevel
# внешний диск смонтирован (до рестарта сервисов на нём)
findmnt /home/oqyude/External
findmnt /mnt/services
# state of guard-зависимостей (когда будет todo B1)
systemctl show postgresql -p Requires -p After | tr ' ' '\n' | grep -E 'mnt-|home-oqyude'
# sops
sops --version
```
## Конвенции проекта
- `xlib` (в `lib/xlib/`) — чистые данные: identity (`device`), capability flags,
директории, helper'ы. Передаётся в каждый модуль через `specialArgs`.
Конфиг не может переопределить `xlib` — единственная точка изменения это
`configurations/default.nix`.
- `device.type` ∈ { minimal, primary, secondary, server, vds, wsl, termux }.
`modules/defaultModule` импортирует `modules/<type>/` через
`lib.optional (!isDesktop && type != "minimal") (./. + "/${type}")`.
- `mkXlib` (`lib/xlib/default.nix:38-77`) — единственная точка сборки xlib.
- Опция живёт в `modules/options.nix`, если её **устанавливает** один модуль,
а **читает** другой. `host.reader.X.enable` живёт в `essentials/ssh.nix`,
потому что его объявляет и использует один модуль.
- `home/<type>.nix` = единственный источник «что есть на этом хосте» для
пользователя; добавление пакета в новый тип = правильный файл, а не
`home/default.nix`.
- `.sops.yaml`: один age-ключ (`*default`), `path_regex: secrets/[^/]+\.(yaml|json|env|ini)$`.
Покрывает только плоские файлы в `secrets/` (без подкаталогов). Дополнительные
секреты dotenv/json — через `mkUserSecret` (`users.nix:33-41`).
+45
View File
@@ -0,0 +1,45 @@
# BOUNDARIES — Рамки и границы
Что агенту **разрешено**, **запрещено** и в каких случаях **нужно остановиться**.
## Разрешено
| Действие | Примечание |
|---|---|
| Читать любые файлы в целевом репозитории | Включая `.git`, конфиги, историю |
| Создавать/изменять файлы в `.agent/` | Директория метаданных проекта (rules, decisions, tasks, context, requests, roadmap, archive) |
| Создавать `.temp/` в корне проекта | Для временных файлов агента. Всегда в `.gitignore` |
| Писать production-код | В фазе EXECUTION, по задачам из `manifest.json` |
| Рефакторить существующий код | Только если это часть задачи в `manifest.json` |
| Делать коммиты | По завершении задачи, перед созданием request |
| Создавать/дополнять `.gitignore` | Только для добавления `.temp/` |
| Устанавливать/обновлять зависимости | Через штатный пакетный менеджер проекта |
| Изменять конфигурационные файлы | Только если необходимо для сборки/тестов |
| Запускать сборку и тесты | Для верификации окружения и проверки request-ов |
| Читать документацию, issue, PRs | Для понимания контекста |
| Запрашивать уточнения у пользователя | Если не хватает информации для декомпозиции |
| Копировать исходники MetaAgent в `.agent/src/` целевого проекта | На фазе INIT, без перезаписи существующих файлов (если не указан `--update`) |
| Создавать/обновлять `AGENTS.md` в корне целевого проекта | Только если файла не существует |
| **Обязательно:** читать `.agent/rules/project-rules.md` перед каждой фазой | Правила пользователя имеют приоритет выше стандартных протоколов |
| Перемещать завершённые артефакты в `.agent/archive/` | На фазах METASTATE и HANDOFF |
| **Обязательно:** после выполнения задачи создавать request в `.agent/requests/active/` | Request — единица результата |
| Вызывать команды из `COMMANDS/` | По явной просьбе пользователя (`/adr`, `/red-team`, `/risk-register`, `/alt-arch`, `/invariant-tests`) |
## Запрещено
| Действие | Почему |
|---|---|
| Удалять файлы | Если файл мешает — сообщить пользователю |
| Менять удалённые настройки CI/CD | Если CI сломан — сообщить пользователю |
| Модифицировать код, не связанный с задачей | Только то, что нужно в рамках задачи из `manifest.json` |
| Выполнять команды (`/adr`, `/red-team`, и т.д.) без явной просьбы | Команды — on-demand, не авто-фаза |
| Задавать пользователю вопросы про depth / scale / фичи | В v3.0 нет шкалы глубины. Просто работай |
## Когда остановиться
1. **Репозиторий не собирается** — сообщить пользователю с логом ошибки, не продолжать.
2. **Неясна цель** — запросить уточнение, не гадать.
3. **Обнаружены секреты/токены** — не копировать, сообщить пользователю.
4. **Цель выходит за рамки одной сессии** — разбить, запросить приоритет.
5. **Проект не использует известные технологии** — запросить инструкцию по сборке.
6. **Непонятно, какую команду вызвать** — спросить пользователя, не угадывать.
+87
View File
@@ -0,0 +1,87 @@
# Changelog
## 3.0.0 — Упрощение модели
**Дата:** 2026-10-08
### Что изменилось
Принята модель «жизненный цикл + команды на вызов» вместо «жизненный цикл с уровнями глубины».
**Удалено:**
- Шкала глубины (depth 1-10) и все её варианты (Scaffold/Light/Standard/Deep/Maximum).
- Условные фичи в фазах: `adr`, `alternative_arch`, `red_team`, `risk_register`, `invariant_tests`.
- Интервью с пользователем на старте (5 вопросов про depth и фичи).
- `.agent/metaagent-request.md` — конфиг-файл, который сейчас не нужен.
- `TEMPLATES/metaagent-request.md`.
**Добавлено:**
- Директория `COMMANDS/` с пятью on-demand инструкциями: `adr.md`, `red-team.md`, `risk-register.md`, `alt-arch.md`, `invariant-tests.md`.
- `GUIDE.md` — заменяет `META_AGENT_GUIDE.md`, описание цикла + список команд.
- `CHANGELOG.md` — этот файл.
**Переименовано / перенумеровано:**
- `META_AGENT_GUIDE.md` → `GUIDE.md`.
- `PROTOCOLS/01_ANALYSIS.md` → `01_ANALYSE.md`.
- `PROTOCOLS/02_DESIGN.md` → `03_DESIGN.md`.
- `PROTOCOLS/03_DECOMPOSITION.md` → `04_DECOMPOSITION.md`.
- `PROTOCOLS/04_EXECUTION.md` → `05_EXECUTION.md`.
- `PROTOCOLS/05_HANDOFF.md` → `07_HANDOFF.md`.
- `PROTOCOLS/06_METASTATE.md` остался под тем же именем (теперь фаза 6).
**Удалены протоколы:**
- `PROTOCOLS/00_CONFIG.md` — конфигурация больше не нужна.
- `PROTOCOLS/00_MIGRATE.md` — миграция теперь документируется в этом CHANGELOG.
- `PROTOCOLS/04_ENVIRONMENT_SETUP.md` — поглощён фазой `00_INIT.md`.
- `PROTOCOLS/02b_REDTEAM.md` — теперь команда `COMMANDS/red-team.md`.
**Структура `.agent/checkpoints.json`** упрощена: убраны `config.depth`, `config.design.adr`, `config.red_team`, `config.risk_register`, `config.decomposition.invariant_tests`.
### Миграция с v2.1 → v3.0
Для проектов, созданных с MetaAgent v2.1:
1. **Удалить** из `.agent/checkpoints.json` секцию `config` целиком (она больше не читается).
2. **Удалить** `.agent/metaagent-request.md` (не используется).
3. **Удалить** `.agent/decisions/config.json`, если есть (аналог config для решений).
4. **Запустить** `install.sh --update` (или `install.ps1 -Update` / `install.bat --update`) — перезапишет исходники MetaAgent.
5. **Переименовать** пути в существующих артефактах: `layer-1/adr/` → `decisions/` (если остались с v1.x), `layer-2/analysis-report.md` → `context/analysis-report.md` и т.п. — это касается только проектов, оставшихся на v1.x.
6. **Записать** в `.agent/checkpoints.json` новое значение `metaagent_version: "3.0.0"`.
`request.json`, `manifest.json`, `decisions/index.json` остаются в том же формате, что в v2.1.
### Экономия
| | v2.1 | v3.0 |
|---|---|---|
| Markdown строк всего | ~3 820 | ~1 800 (целевой) |
| Протоколов | 10 | 8 |
| Уровней конфигурации | 5 (depth) | 0 |
---
## 2.1.0 — Project Loop + Work Loop + Requests
**Дата:** 2025-08 (предыдущая версия)
- Введён двухконтурный жизненный цикл: Project Loop (однократно) + Work Loop (циклически).
- Добавлены фазы: ROADMAP, METASTATE, RED_TEAM.
- Введены `requests/` как единица результата выполненной задачи.
- Введён `metaagent-request.md` с конфигом сессии (depth scale, фичи).
- Введена структура `.agent/` с семантическими директориями: `decisions/`, `tasks/`, `context/`, `rules/`, `requests/`, `roadmap/`, `archive/`.
- Шкала глубины 1-10 с условными фичами (adr, alternative_arch, red_team, risk_register, invariant_tests).
## 2.0.0 — Реструктуризация `.agent/`
- Переход от слоистой структуры `layer-0..3` к семантическим директориям.
- Полный MIGRATE-протокол для апгрейда с v1.x.
## 1.1.0 — Добавлены rules, archive
- `PROTOCOLS/01_ANALYSIS.md` обзавёлся правилами из `.agent/rules/`.
- Добавлена директория `archive/`.
## 1.0.0 — Первый релиз
- Односессионный pipeline: INIT → ANALYSE → DECOMP → SETUP → HANDOFF.
- Структура `layer-0..3`.
+95
View File
@@ -0,0 +1,95 @@
# ADR — Architecture Decision Record
## Назначение
Зафиксировать архитектурное решение в `.agent/decisions/NNN-slug.md` так, чтобы будущий агент (или человек) мог понять: что решили, почему, какие альтернативы рассматривали, какие последствия.
ADR создаются по явной команде пользователя: «запиши это как решение», «/adr», «сделай ADR для текущего подхода».
## Когда вызывать
- Принято неочевидное архитектурное решение (выбор БД, паттерна, библиотеки, структуры модулей).
- Решение может измениться в будущем — стоит зафиксировать контекст.
- Есть trade-off, который нужно объяснить следующему агенту.
Не вызывать для очевидных вещей: «используем pytest», «классы называем в PascalCase».
## Вход
- Контекст решения: что обсуждалось, какие варианты сравнивались, что выбрали.
- `.agent/decisions/index.json` — текущий список ADR (для нумерации).
- `.agent/context/project-state.md` — текущее состояние проекта.
## Шаги
### 1. Определить номер
Прочитать `.agent/decisions/index.json`. Следующий номер = max существующих + 1. Если файла нет — создать, начать с 001.
### 2. Slug
Короткое имя в kebab-case, отражающее суть: `use-sqlite-for-mvp`, `auth-via-jwt-cookies`, `modular-monolith`.
### 3. Записать ADR
Создать `.agent/decisions/{NNN}-{slug}.md` по шаблону `TEMPLATES/adr-NNNN.md`:
```markdown
# {NNN}. {Заголовок}
**Дата:** {YYYY-MM-DD}
**Статус:** Accepted | Superseded by {NNN} | Deprecated
## Контекст
{Что за проблема. Какие ограничения. Что нужно было решить.}
## Решение
{Что выбрали. Коротко и конкретно.}
## Альтернативы, которые рассмотрели
### {Альтернатива 1}
{Описание. Почему не выбрали.}
### {Альтернатива 2}
{Описание. Почему не выбрали.}
## Последствия
### Положительные
- {что становится лучше}
### Отрицательные
- {что становится хуже или сложнее}
### Инварианты
- {что не должно сломаться, чтобы решение оставалось валидным}
```
### 4. Обновить index.json
```json
{
"version": "3.0.0",
"decisions": [
{ "id": "001", "title": "Использовать SQLite для MVP", "file": "001-use-sqlite-for-mvp.md", "status": "Accepted" }
],
"last_updated": "{timestamp}"
}
```
### 5. Если есть supersession
Если новый ADR отменяет старый — в старом ADR поставить `Статус: Superseded by {NNN}` и добавить ссылку. В новом — в контексте упомянуть, что отменяет.
## Выход
- `.agent/decisions/{NNN}-{slug}.md`
- Обновлённый `.agent/decisions/index.json`
## Связанные команды
- **/invariant-tests** — после ADR можно зафиксировать инварианты как задачи в manifest.
- **/alt-arch** — если хочется явно зафиксировать альтернативу до решения.
+85
View File
@@ -0,0 +1,85 @@
# Alternative Architecture
## Назначение
Описать альтернативный вариант архитектуры / подхода, чтобы сравнить с текущим и принять осознанное решение. Не «сделать вместо», а «сравнить и выбрать».
## Когда вызывать
- Текущий дизайн кажется спорным, нужна трезвая оценка альтернативы.
- Хочется зафиксировать «почему не сделали иначе» — потом пригодится при росте.
- Перед крупным решением (выбор БД, монолит-vs-микросервисы, sync-vs-async).
## Вход
- Текущий дизайн / план (`.agent/context/design-report.md` или текущее состояние).
- Ограничения проекта (сроки, стек, бюджет).
## Шаги
### 1. Определить, что сравниваем
Один конкретный вопрос: «SQLite vs PostgreSQL», «монолит vs микросервисы», «REST vs GraphQL», «sync-обработка vs очередь».
### 2. Сформулировать альтернативу
Краткое описание: что предлагается вместо текущего подхода. Без длинного дизайна — на уровне «как это работает и чем отличается».
### 3. Сравнить
| Аспект | Текущий | Альтернатива |
|---|---|---|
| Сложность реализации | | |
| Время до MVP | | |
| Производительность | | |
| Масштабирование | | |
| Поддерживаемость | | |
| Стоимость изменений | | |
| Риски | | |
### 4. Записать
Создать `.agent/context/alt-architecture.md` (если файла нет) или дополнить. Структура:
```markdown
# Alternative Architecture — {что сравниваем}
**Дата:** {YYYY-MM-DD}
## Контекст
{Почему рассматриваем альтернативу. Что не устраивает в текущем.}
## Альтернатива
{Краткое описание. Архитектура, ключевые компоненты, поток данных.}
## Сравнение
{Таблица из шага 3.}
## Когда альтернатива выигрывает
{В каких условиях стоит переключиться. Триггеры для миграции.}
## Когда остаёмся на текущем
{Что в текущем работает достаточно хорошо, чтобы не менять.}
## Рекомендация
{Остаёмся или мигрируем. Почему.}
```
### 5. Связать с ADR
Если после сравнения принимается решение — использовать **/adr** для фиксации. Альтернативный файл остаётся как исторический артефакт.
## Выход
- `.agent/context/alt-architecture.md`
## Связанные команды
- **/adr** — зафиксировать итоговое решение.
- **/risk-register** — если альтернатива снимает/добавляет риски.
+68
View File
@@ -0,0 +1,68 @@
# Invariant Tests
## Назначение
Превратить инварианты из ADR в задачи-тесты в `.agent/tasks/manifest.json`. Инвариант — это «что не должно сломаться, чтобы ADR оставался валидным». Без явного теста это просто слова.
## Когда вызывать
- После создания ADR, в секции «Инварианты» которого перечислены условия валидности решения.
- Когда хочется, чтобы архитектурные решения были защищены регрессионными тестами.
## Вход
- `.agent/decisions/*.md` — ADR с секцией «Инварианты».
- `.agent/tasks/manifest.json` — текущий манифест (для нумерации задач).
## Шаги
### 1. Найти ADR с инвариантами
Прочитать все `.agent/decisions/*.md`, найти секции «Инварианты».
### 2. Для каждого инварианта — задача
Каждый инвариант = одна задача-тест. Формат:
```json
{
"id": "T-INV-001",
"title": "Invariant: auth-сессия не переживает рестарт сервиса",
"type": "test",
"origin": "invariant:001",
"depends_on": [],
"acceptance_criteria": [
"Тест рестартит auth-сервис и проверяет, что все сессии инвалидированы",
"Тест проверяет, что refresh-токен не работает после рестарта"
],
"files": [
"tests/auth/test_invariants.py"
]
}
```
### 3. Добавить в manifest
Записать задачи в `.agent/tasks/manifest.json` с `status: "pending"`. Связать `depends_on` с задачами, которые реализуют компонент (если ещё не выполнены).
### 4. Связать с ADR
В самом ADR добавить (опционально) ссылку на задачу-инвариант:
```markdown
## Инварианты
- {{ ... }}
### Покрытие тестами
- T-INV-001: ...
```
## Выход
- Новые задачи в `.agent/tasks/manifest.json` с `origin: "invariant:{adr_id}"`
- (опционально) обновлённый ADR со ссылкой на задачи
## Связанные команды
- **/adr** — источник инвариантов.
- **/risk-register** — некоторые инварианты рождаются из рисков.
+104
View File
@@ -0,0 +1,104 @@
# Red Team Review
## Назначение
Попытаться сломать текущий дизайн / архитектуру / план. Зафиксировать найденные уязвимости в `.agent/context/red-team-report.md`, чтобы разработчик мог их закрыть до реализации.
Red Team — это adversarial-проход по дизайну. Не «улучшить», а «найти, что не так».
## Когда вызывать
- После фазы DESIGN, до декомпозиции задач.
- Когда дизайн кажется слишком гладким.
- Перед крупным рефакторингом.
- Когда непонятно, какие риски у текущего подхода.
## Вход
- `.agent/context/design-report.md` (если есть).
- `.agent/decisions/*.md` — связанные ADR.
- `.agent/context/project-state.md` — текущее состояние.
## Шаги
### 1. Прочитать целевой дизайн
Понять, что именно ревьюится: вся архитектура, конкретный модуль, конкретное решение.
### 2. Провести атаки по категориям
#### 2.1. Нагрузка и масштабирование
- Что будет при 10x / 100x объёма?
- Где узкое место?
- Что сломается первым?
#### 2.2. Отказы и доступность
- Что если упадёт БД / кэш / внешний сервис?
- Есть ли SPOF (single point of failure)?
- Как восстанавливаемся?
#### 2.3. Безопасность
- Где хранятся секреты?
- Какие поверхности атаки?
- Что с аутентификацией / авторизацией?
- Injection, SSRF, XSS — что релевантно?
#### 2.4. Корректность
- Где гонки (race conditions)?
- Что с консистентностью данных?
- Какие edge cases не покрыты?
#### 2.5. Поддерживаемость
- Что будет сложно менять через год?
- Где связность, которую придётся разрывать?
- Какие зависимости могут устареть?
#### 2.6. Миграция и совместимость
- Если меняем API — как старые клиенты переживут?
- Если меняем схему БД — что со старыми данными?
- Если выкатываем поэтапно — какой план?
### 3. Записать отчёт
Создать `.agent/context/red-team-report.md` (если файла нет) или дополнить:
```markdown
# Red Team Review — {что ревьюим}
**Дата:** {YYYY-MM-DD}
**Цель:** {что именно атакуем}
## Критические находки
### R1. {Краткое название}
- **Категория:** безопасность / нагрузка / корректность / ...
- **Сценарий:** {как воспроизвести}
- **Воздействие:** {что произойдёт}
- **Рекомендация:** {что сделать}
## Существенные находки
### R2. ...
## Минорные находки
### R3. ...
## Что выдержало атаку
- {Что оказалось надёжным — это тоже полезно знать.}
```
### 4. Связать с задачами
Если находка превращается в задачу — добавить в `.agent/tasks/manifest.json` (фаза DECOMPOSITION) с `origin: "red-team:{номер_находки}"`.
## Выход
- `.agent/context/red-team-report.md`
- (опционально) новые задачи в manifest
## Связанные команды
- **/adr** — если Red Team выявил, что нужно зафиксировать решение иначе.
- **/risk-register** — для систематизации рисков.
+80
View File
@@ -0,0 +1,80 @@
# Risk Register
## Назначение
Явный реестр допущений и рисков проекта в `.agent/context/risk-register.md`. Чтобы не держать в голове «ну мы же понимаем, что X может сломаться» — а записать, оценить и (если надо) превратить в задачи.
## Когда вызывать
- В начале проекта — зафиксировать стартовые допущения.
- При появлении нового риска (новый внешний сервис, новая зависимость, новое требование).
- При обзоре дизайна (после DESIGN или Red Team).
## Вход
- `.agent/context/design-report.md` (если есть).
- `.agent/context/analysis-report.md` — что уже знаем о проекте.
- `.agent/decisions/*.md` — принятые решения (могут быть источниками рисков).
## Шаги
### 1. Собрать риски
Источники:
- Допущения, на которых держится дизайн («считаем, что PostgreSQL выдержит 1k qps»).
- Внешние зависимости без SLA.
- Технологии, которые команда не знает.
- Сроки, которые давят.
- Решения, которые сложно откатить.
### 2. Оценить каждый риск
По двум осям:
- **Вероятность** (1-низкая, 2-средняя, 3-высокая).
- **Воздействие** (1-небольшое, 2-серьёзное, 3-критическое).
`score = вероятность × воздействие` (1-9).
### 3. Записать
Создать или дополнить `.agent/context/risk-register.md` по шаблону `TEMPLATES/risk-register.md`:
```markdown
# Risk Register
**Дата:** {YYYY-MM-DD}
## Высокий риск (score 6-9)
### R-001. {Краткое название}
- **Категория:** технический / продуктовый / организационный
- **Описание:** {что может пойти не так}
- **Воздействие:** {что будет если случится}
- **Вероятность:** 3 / 2 / 1
- **Счёт:** 9 / 6 / 4
- **Митигация:** {что делаем чтобы уменьшить}
- **Владелец:** {кто отвечает}
- **Статус:** open / mitigated / accepted / closed
## Средний риск (score 3-4)
...
## Низкий риск (score 1-2)
...
## Закрытые риски
...
```
### 4. Связать с задачами
Если риск требует действия — добавить задачу в `.agent/tasks/manifest.json` с `origin: "risk:R-001"`.
## Выход
- `.agent/context/risk-register.md`
## Связанные команды
- **/red-team** — источник технических рисков.
- **/adr** — некоторые риски закрываются через принятое решение.
+205
View File
@@ -0,0 +1,205 @@
# MetaAgent GUIDE v3.0
MetaAgent — набор инструкций для AI-агента. Задача: превратить хаотичное общение с агентом в структурированный процесс, в котором состояние проекта переживает любую сессию.
## Два слоя
- **Цикл** (всегда, по необходимости) — последовательность фаз, которую агент проходит при работе с проектом.
- **Команды** (по запросу пользователя) — on-demand инструкции, которые не привязаны к фазе.
Состояние проекта живёт в `.agent/` целевого репозитория. Следующий агент читает `.agent/` и не лезет в исходники.
---
## Цикл
```
.agent/checkpoints.json
│
▼
┌─────────────────────────────────────┐
│ PROJECT LOOP (разово) │
│ │
│ INIT → ANALYSE → ROADMAP → │
│ → [DESIGN] → DECOMPOSITION │
│ │
│ Выход: .agent/tasks/manifest.json │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ WORK LOOP (циклически) │
│ │
│ EXECUTION → (request) → │
│ → METASTATE (по команде) │
│ │
│ Беру задачу → делаю → request → │
│ накопилось → METASTATE │
└──────────────────┬──────────────────┘
│
▼
HANDOFF (завершение)
```
Фазы выполняются **строго последовательно** внутри PROJECT LOOP. WORK LOOP повторяется многократно.
### Ветвление
| Тип проекта | Цикл |
|---|---|
| **existing** | INIT → ANALYSE → ROADMAP → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF |
| **greenfield / scaffold** | + фаза DESIGN между ROADMAP и DECOMPOSITION |
Тип проекта определяется автоматически в фазе ANALYSE. Никакого интервью с пользователем, никакой шкалы глубины.
---
## Фазы
| # | Фаза | Протокол | Что делает |
|---|---|---|---|
| 0 | INIT | `PROTOCOLS/00_INIT.md` | Создаёт `.agent/`, ставит исходники, инициализирует checkpoints |
| 1 | ANALYSE | `PROTOCOLS/01_ANALYSE.md` | Сканирует проект, создаёт `analysis-report.md` + начальный `project-state.md` |
| 2 | ROADMAP | `PROTOCOLS/02_ROADMAP.md` | Собирает источники задач (FUTURE, ADR, user-запросы) → `roadmap/sources.md` |
| 3 | DESIGN | `PROTOCOLS/03_DESIGN.md` | Только greenfield. Архитектура, модули, API, модели |
| 4 | DECOMPOSITION | `PROTOCOLS/04_DECOMPOSITION.md` | Разбивает цель на атомарные задачи → `tasks/manifest.json` |
| 5 | EXECUTION | `PROTOCOLS/05_EXECUTION.md` | Цикл: берёт задачу → код → тесты → коммит → request |
| 6 | METASTATE | `PROTOCOLS/06_METASTATE.md` | По команде. Ревью requests, обновление project-state, handoff-summary |
| 7 | HANDOFF | `PROTOCOLS/07_HANDOFF.md` | Валидация `.agent/`, финализация checkpoints, session-summary |
---
## Команды
Эти инструкции выполняются **по явной просьбе пользователя** в любой момент сессии. Они не привязаны к фазе.
| Команда | Файл | Что делает |
|---|---|---|
| «запиши ADR» / «/adr» | `COMMANDS/adr.md` | Создаёт `.agent/decisions/NNN-slug.md` |
| «red team» / «/red-team» | `COMMANDS/red-team.md` | Создаёт `.agent/context/red-team-report.md` — попытка сломать дизайн |
| «risk register» / «/risk-register» | `COMMANDS/risk-register.md` | Создаёт `.agent/context/risk-register.md` |
| «альтернативная архитектура» / «/alt-arch» | `COMMANDS/alt-arch.md` | Описывает альтернативу текущему дизайну |
| «invariant-тесты» / «/invariant-tests» | `COMMANDS/invariant-tests.md` | Создаёт задачи-инварианты для ADR |
### Когда вызывать
- **ADR** — после архитектурного решения, которое нужно зафиксировать. Типично во время DESIGN или при появлении неочевидного выбора в EXECUTION.
- **Red Team** — после готового дизайна, чтобы найти слабые места до реализации.
- **Risk Register** — в начале проекта или при появлении новых допущений.
- **Alt Arch** — если сомневаетесь в выбранном подходе, хотите сравнить варианты.
- **Invariant Tests** — после ADR, чтобы зафиксировать «что не должно сломаться».
Команды **не обязательны**. Если не вызваны — не выполняются. Состояние проекта от них не зависит.
---
## Структура `.agent/`
```
.agent/
checkpoints.json # состояние сессии (ядро)
session-summary.md # краткая сводка сессии
handoff-summary.md # сводка для следующего агента (создаётся METASTATE)
src/ # исходники MetaAgent (всегда)
GUIDE.md
BOUNDARIES.md
CHANGELOG.md
PROTOCOLS/
COMMANDS/
TEMPLATES/
VERSION
install.sh / install.ps1
rules/
project-rules.md # ваши правила — читать перед каждой фазой
roadmap/ # источники задач
sources.md
archive/
decisions/ # ADR
index.json
001-*.md
tasks/ # задачи
manifest.json + manifest.md
backlog/
requests/ # результаты выполненных задач
active/ # ready_for_review
archive/ # approved / rejected
context/
analysis-report.md
project-state.md # обновляется в METASTATE
design-report.md # только greenfield
red-team-report.md # если вызывали /red-team
risk-register.md # если вызывали /risk-register
baseline-test-report.log
archive/
index.json
tasks/
decisions/
requests/
checkpoints/
```
`.temp/` в корне проекта — для временных файлов агента. Всегда в `.gitignore`.
---
## Checkpoints
`checkpoints.json` обновляется после каждой фазы:
```json
{
"metaagent_version": "3.0.0",
"session_id": "<uuid>",
"target_repo": "<path>",
"goal": "<цель>",
"project_type": "existing | greenfield | scaffold",
"phases": {
"init": "completed",
"analyse": "completed",
"roadmap": "completed",
"design": "skipped",
"decomposition": "completed",
"execution": "in_progress",
"metastate": "pending",
"handoff": "pending"
},
"tasks": [
{ "id": "T1", "title": "...", "status": "in_progress", "origin": "user:direct" }
],
"last_updated": "<timestamp>"
}
```
Секции `config` больше нет. Параметры, которые раньше были в `config` (depth, adr, red_team и т.п.), теперь либо не существуют, либо живут в отдельных командах.
---
## Принципы
### Цикл vs команды
Цикл — это «что агент делает по умолчанию». Команды — «что агент делает по явной просьбе». Не путать: ADR не запускается автоматически в DESIGN, а только когда пользователь скажет «запиши это как решение».
### `.agent/` как слепок проекта
После METASTATE `.agent/` содержит всю картину. Следующий агент читает только `.agent/`, не исходники.
### Request — единица результата
Каждая выполненная задача в EXECUTION завершается созданием `request` (`.agent/requests/active/req-{id}.json`). Request содержит суть изменений, коммиты, верификацию, закрытые acceptance criteria. Ревью request-ов происходит в METASTATE.
### Правила выше протоколов
Перед каждой фазой читать `.agent/rules/project-rules.md`. Если правило пользователя противоречит протоколу — следовать правилу.
### Контекст бесконечно не растёт
Завершённые задачи архивируются в `.agent/archive/tasks/`, request-ы — в `.agent/requests/archive/`. Текущий manifest остаётся lean.
+128
View File
@@ -0,0 +1,128 @@
# Протокол 00: Инициализация (INIT)
## Цель
Подготовить `.agent/` в целевом репозитории: установить исходники MetaAgent, создать структуру директорий, инициализировать `checkpoints.json`, создать/обновить `AGENTS.md`.
INIT выполняется **один раз** в начале работы с проектом. Если `.agent/` уже существует и инициализирован — пропускается.
## Вход
- Целевой репозиторий (путь или текущая директория)
- `VERSION` — текущая версия MetaAgent
- Опционально: существующий `.agent/` (если обновление)
## Шаги
### 0.1. Определить целевой репозиторий
Если не указан явно — текущая рабочая директория. Если указан как URL — клонировать во временную директорию, дальше работать с копией.
### 0.2. Проверить существующий `.agent/`
Если `.agent/` существует:
- Прочитать `.agent/checkpoints.json` → `metaagent_version`
- Если `metaagent_version == VERSION` → INIT уже выполнен, выйти
- Если версия старше → запустить `install.sh --update` (Unix) или `install.ps1 -Update` (Windows) для переустановки исходников, затем выйти
- Если `.agent/` есть, но `checkpoints.json` отсутствует → продолжить INIT (создать checkpoints)
Если `.agent/` не существует → продолжить INIT.
### 0.3. Создать структуру `.agent/`
Создать директории:
```
.agent/
src/ # исходники MetaAgent (копируются из METAAGENT_SRC)
rules/
decisions/
tasks/
backlog/
context/
requests/
active/
archive/
roadmap/
archive/
archive/
tasks/
decisions/
requests/
checkpoints/
```
### 0.4. Создать `.temp/` в корне проекта
Если не существует — создать `.temp/` в корне целевого репозитория. Добавить в `.gitignore` (если его нет — создать с одной строкой `.temp/`).
### 0.5. Скопировать исходники MetaAgent
Скопировать в `.agent/src/`:
- `GUIDE.md`
- `BOUNDARIES.md`
- `CHANGELOG.md`
- `VERSION`
- `PROTOCOLS/`
- `COMMANDS/`
- `TEMPLATES/`
- `install.sh`, `install.ps1`
Существующие файлы в `.agent/src/` не перезаписывать (только с явным `--update`).
### 0.6. Создать `.agent/rules/project-rules.md`
Если файла нет — создать по шаблону `TEMPLATES/project-rules.md`.
### 0.7. Создать/обновить `AGENTS.md` в корне
Если `AGENTS.md` в корне проекта отсутствует — создать по `AGENTS.template.md` с подставленной версией.
Если существует и не относится к MetaAgent — не трогать (попросить пользователя переименовать или подтвердить перезапись).
### 0.8. Инициализировать `checkpoints.json`
Создать `.agent/checkpoints.json`:
```json
{
"metaagent_version": "3.0.0",
"session_id": "<uuid>",
"target_repo": "<путь>",
"goal": null,
"project_type": null,
"phases": {
"init": "completed",
"analyse": "pending",
"roadmap": "pending",
"design": "pending",
"decomposition": "pending",
"execution": "pending",
"metastate": "pending",
"handoff": "pending"
},
"tasks": [],
"last_updated": "<timestamp>"
}
```
Поля `goal` и `project_type` остаются `null` до фазы ANALYSE (goal может быть задан пользователем заранее — тогда заполнить сразу).
## Выход
- `.agent/` с полной структурой
- `.agent/src/` с актуальными исходниками MetaAgent
- `.agent/rules/project-rules.md`
- `.agent/checkpoints.json` со `session_id` и `phases.init = "completed"`
- `AGENTS.md` в корне проекта
- `.temp/` в корне + `.gitignore` обновлён
## Критерии завершения
- [ ] `.agent/` содержит все обязательные директории
- [ ] `.agent/src/` содержит GUIDE.md, PROTOCOLS/, COMMANDS/, TEMPLATES/, VERSION
- [ ] `.agent/checkpoints.json` валиден (JSON parse)
- [ ] `AGENTS.md` присутствует в корне
- [ ] `.temp/` существует и в `.gitignore`
+95
View File
@@ -0,0 +1,95 @@
# Протокол 01: Анализ репозитория (ANALYSE)
## Цель
Составить полную картину целевого репозитория: тип проекта, стек, архитектура, конвенции, состояние тестов. Создать начальный слепок проекта.
## Вход
- Целевой репозиторий
- `.agent/checkpoints.json` (фаза analyse: pending)
- `.agent/rules/project-rules.md` — прочитать первым
## Шаги
### 1.1. Прочитать правила проекта
Прежде чем что-либо делать — прочитать `.agent/rules/project-rules.md`. Если есть правила, применить их к фазе.
### 1.2. Определить тип проекта
Просканировать корень репозитория:
- **`existing`** — есть исходный код, тесты, система сборки (`.py`, `.js`, `.ts`, `.rs`, `.go` и т.д. помимо конфигов и README).
- **`greenfield`** — пусто или только README/LICENSE/.gitignore.
- **`scaffold`** — есть базовая структура (`pyproject.toml`/`package.json`), но нет значимого кода.
Записать тип в `checkpoints.json → project_type`.
### 1.3. Сканировать проект
Для `existing` / `scaffold` собрать:
- **README** — описание, инструкции по сборке/тестам.
- **Лицензия** — какой LICENSE.
- **CI/CD** — `.github/workflows/`, `.gitlab-ci.yml`, `Jenkinsfile`, `Makefile`.
- **Стек** — язык, фреймворк, БД, тестовый раннер, пакетный менеджер, линтер.
- **Структура** — `tree -L 3` (не более 3 уровней).
- **Архитектурный паттерн** — MVC, модульный монолит, микросервисы, слоистая.
- **Ключевые модули/пакеты** — список с краткой ответственностью.
- **Конвенции** — стиль, именование, обработка ошибок, логирование.
- **Тесты** — где лежат, как запускаются, текущее состояние (запустить).
- **Сборка** — выполняется ли проект.
Для `greenfield` — извлечь требования из README:
- Функциональные требования (user stories, сценарии).
- Нефункциональные (стек, производительность, безопасность).
- Бизнес-контекст (зачем, для кого).
- Сомнительные / неясные требования (вопросы пользователю).
### 1.4. Создать analysis-report
Записать `.agent/context/analysis-report.md` по шаблону `TEMPLATES/analysis-report.md`. Заполнить соответствующие секции.
### 1.5. Создать начальный project-state
Создать `.agent/context/project-state.md` по шаблону `TEMPLATES/project-state.md`. Это **начальный** слепок. В дальнейшем обновляется в фазе METASTATE.
Заполнить:
- Тип проекта
- Краткая архитектура (из шага 1.3)
- Ключевые модули и их статус
- Tech stack
- Статус тестов
### 1.6. Обновить checkpoints
```json
{
"phases": { "analyse": "completed" },
"project_type": "existing | greenfield | scaffold",
"last_updated": "<timestamp>"
}
```
## Ветвление
| project_type | Следующая фаза |
|---|---|
| `existing` | ROADMAP → DECOMPOSITION (DESIGN пропускается) |
| `greenfield` | ROADMAP → DESIGN → DECOMPOSITION |
| `scaffold` | ROADMAP → DESIGN → DECOMPOSITION |
## Выход
- `.agent/context/analysis-report.md`
- `.agent/context/project-state.md` (начальный)
- Обновлённый `checkpoints.json`
## Критерии завершения
- [ ] Тип проекта определён
- [ ] `analysis-report.md` содержит все соответствующие секции
- [ ] `project-state.md` создан с начальным слепком
- [ ] `checkpoints.json` обновлён
+106
View File
@@ -0,0 +1,106 @@
# Протокол 02: Дорожная карта (ROADMAP)
## Цель
Собрать все источники задач для проекта, приоритизировать их и записать в `.agent/roadmap/sources.md`. ROADMAP — мост между видением проекта и конкретными задачами в манифесте.
## Вход
- `.agent/context/analysis-report.md`
- Цель сессии (goal из `checkpoints.json` или запрос пользователя)
- `FUTURE/` — директория долгосрочных планов (если существует)
- `.agent/decisions/index.json` — принятые ADR (опционально)
- `.agent/checkpoints.json` (фаза roadmap: pending)
- `.agent/rules/project-rules.md` — прочитать первым
## Шаги
### 2.1. Прочитать правила проекта
Прочитать `.agent/rules/project-rules.md`, применить к фазе.
### 2.2. Сканировать FUTURE/
Если в корне проекта существует `FUTURE/`:
- Прочитать все `.md` файлы.
- Зафиксировать: название, статус (active/archived), приоритет, зависимости.
- Какие планы реализованы, какие ожидают.
### 2.3. Сканировать ADR
Если существует `.agent/decisions/index.json`:
- Прочитать индекс ADR.
- Определить, какие решения требуют реализации (не все ADR технические).
- Для каждого — сформулировать задачу-кандидат.
### 2.4. Собрать внешние источники
- Запрос пользователя (goal).
- issues / feedback (если доступны).
- Tech debt, выявленный в ANALYSE.
### 2.5. Приоритизировать
Присвоить каждой задаче приоритет:
| Приоритет | Описание |
|---|---|
| **P0** | Критично, делать следующим |
| **P1** | Важно, сделать скоро |
| **P2** | Желательно |
| **P3** | Долгосрочно / отложено |
Правила:
- Блокирующие зависимости поднимают приоритет.
- User-запросы получают P0-P1 по умолчанию.
- ADR-задачи получают приоритет по срочности решения.
### 2.6. Создать sources.md
Создать `.agent/roadmap/sources.md` по шаблону `TEMPLATES/roadmap-sources.md`:
```markdown
# Roadmap Sources
## FUTURE Plans
| План | Приоритет | Статус |
|------|-----------|--------|
## ADR-Derived Tasks
| ADR | Задача | Приоритет |
|-----|--------|-----------|
## User Requests
| Запрос | Приоритет | Источник |
|--------|-----------|----------|
## Agent-Identified Improvements
| Наблюдение | Задача | Приоритет |
|------------|--------|-----------|
## Consolidated Priority Queue
1. task (origin) — P0
```
### 2.7. Архивация
Если в `.agent/roadmap/archive/` есть предыдущие версии — оставить справочно.
Если планы из `FUTURE/*` больше не актуальны — переместить в `FUTURE/archive/`.
## Выход
- `.agent/roadmap/sources.md`
- Возможно обновлённый `FUTURE/`
- `checkpoints.json: phases.roadmap = "completed"`
## Критерии завершения
- [ ] Все источники просканированы (FUTURE, ADR, user, agent)
- [ ] `sources.md` создан с приоритетами P0-P3
- [ ] Каждая задача имеет origin-ссылку на источник
- [ ] Устаревшие планы перемещены в archive
- [ ] `checkpoints.json` обновлён
+142
View File
@@ -0,0 +1,142 @@
# Протокол 03: Архитектурное проектирование (DESIGN)
## Цель
Спроектировать архитектуру, модули, данные и интерфейсы для greenfield/scaffold-проекта.
DESIGN выполняется **только** для `project_type = greenfield` или `scaffold`. Для existing-проектов пропускается.
## Вход
- `.agent/context/analysis-report.md` (project_type: greenfield или scaffold)
- `.agent/roadmap/sources.md` (опционально)
- `.agent/checkpoints.json` (фаза design: pending)
- `.agent/rules/project-rules.md` — прочитать первым
## Правила
1. **Реалистичность** — архитектура реализуема за 1 сессию (до 10 задач).
2. **Документируемость** — каждый модуль, модель и интерфейс описывается в `design-report.md`.
3. **Тестируемость** — каждый компонент проектируется с учётом тестирования.
4. **Итеративность** — первая версия минимально рабочая (MVP), расширения — отдельными задачами.
## Шаги
### 3.1. Прочитать правила проекта
Прочитать `.agent/rules/project-rules.md`, применить.
### 3.2. Технологический стек
Если стек не указан в README — предложить обоснованный выбор. Если указан — зафиксировать.
Для каждого компонента:
- Язык и версия
- Фреймворк / библиотека
- База данных (движок, схема)
- Инфраструктура (Docker, CI/CD, хостинг)
### 3.3. High-level архитектура
- **Паттерн** — монолит, модульный монолит, микросервисы, слоистая, луковая.
- **Компоненты** — что делает каждый модуль/сервис.
- **Схема взаимодействия** — текстовое описание потоков данных.
```
[Client] → HTTP → [API Gateway] → [Auth Service]
↓
[Core Service] → [Database]
↓
[External API] → [3rd Party]
```
### 3.4. Модули
| Поле | Описание |
|---|---|
| Имя модуля | `app/services/cashflow.py` |
| Ответственность | Что делает |
| Ключевые классы/функции | Сигнатуры без реализации |
| Зависимости | Какие модули нужны |
| Контракт | Что экспортирует |
### 3.5. Модели данных
Описать сущности, поля, связи:
```json
{
"entity": "Transaction",
"fields": [
{"name": "id", "type": "UUID", "pk": true},
{"name": "amount", "type": "Decimal"},
{"name": "date", "type": "datetime"},
{"name": "category_id", "type": "UUID", "fk": "Category"}
]
}
```
### 3.6. API интерфейсы
| Метод | Путь | Описание | Request | Response | Статусы |
|---|---|---|---|---|---|
| GET | /transactions | Список | ?page, ?limit | [Transaction] | 200 |
| POST | /transactions | Создать | CreateTransactionDTO | Transaction | 201, 400 |
Если GUI — ключевые страницы. Если CLI — команды.
### 3.7. Обработка ошибок
- Стратегия: исключения / Result / коды.
- Формат API-ошибок: `{ "error": "...", "code": "...", "details": {} }`.
- Логирование: уровни для разных событий.
### 3.8. Стратегия тестирования
- Какие тесты нужны (unit, integration, e2e).
- Как изолировать зависимости.
- Команда запуска тестов.
### 3.9. Группировка в задачи
Предварительно наметить задачи по модулям — вход для DECOMPOSITION:
```
T1: Инициализация проекта + зависимости
T2: Модель данных (сущности, миграции)
T3: Service (core logic)
T4: API endpoints
T5: Tests
```
### 3.10. Создать design-report
Записать `.agent/context/design-report.md` по шаблону `TEMPLATES/design-report.md`.
### 3.11. Дополнительно (по команде пользователя)
Эти шаги **не выполняются автоматически** — только если пользователь явно попросил:
- **ADR** — вызвать `COMMANDS/adr.md` для ключевых решений.
- **Alternative Architecture** — вызвать `COMMANDS/alt-arch.md` для сравнения.
- **Risk Register** — вызвать `COMMANDS/risk-register.md` для допущений.
- **Red Team** — вызвать `COMMANDS/red-team.md` для атаки на дизайн.
## Выход
- `.agent/context/design-report.md`
- Предварительная группировка задач (для DECOMPOSITION)
- Возможно: ADR, risk-register, alt-architecture, red-team-report (если вызывали команды)
- `checkpoints.json: phases.design = "completed"`
## Критерии завершения
- [ ] Стек определён
- [ ] High-level архитектура описана
- [ ] Модули и их ответственность описаны
- [ ] Модели данных спроектированы
- [ ] API/интерфейсы описаны (если применимо)
- [ ] Стратегия тестирования определена
- [ ] Задачи предварительно сгруппированы
- [ ] `design-report.md` создан
- [ ] `checkpoints.json` обновлён
+117
View File
@@ -0,0 +1,117 @@
# Протокол 04: Декомпозиция задач (DECOMPOSITION)
## Цель
Разбить цель пользователя (и архитектурный план, если есть) на атомарные, независимо выполнимые задачи. Записать в `manifest.json` + `manifest.md`.
## Вход
- `.agent/context/analysis-report.md`
- `.agent/context/design-report.md` (опционально — для greenfield)
- `.agent/roadmap/sources.md` (опционально)
- `.agent/decisions/*.md` (опционально)
- Цель пользователя (goal из `checkpoints.json`)
- `.agent/rules/project-rules.md` — прочитать первым
- `.agent/checkpoints.json` (фаза decomposition: pending)
## Принципы
1. **Атомарность** — одна задача = одна логическая единица, выполнимая и проверяемая за один подход.
2. **Независимость (макс.)** — минимизировать зависимости между задачами.
3. **Тестируемость** — каждая задача имеет измеримые acceptance criteria.
4. **Границы** — задача не выходит за пределы `BOUNDARIES.md`.
5. **Порядок** — задачи с зависимостями выполняются строго последовательно.
## Шаги
### 4.1. Прочитать правила проекта
Прочитать `.agent/rules/project-rules.md`, применить.
### 4.2. Размер задачи
Задача должна укладываться в **1-2 часа работы агента**. Если крупнее — разбить.
Признак слишком крупной задачи:
- Нельзя сформулировать acceptance criteria одной строкой.
- Затрагивает 5+ файлов.
- Содержит союзы «и», «а также», «после чего».
### 4.3. Сверить с roadmap
Если существует `.agent/roadmap/sources.md`:
- Задачи из roadmap получают приоритет P0-P3 в соответствии с `sources.md`.
- Задачи без явного источника получают `origin: "decomposition"`.
### 4.4. Структура задачи
| Поле | Описание | Пример |
|---|---|---|
| `id` | Уникальный идентификатор | `T1`, `T2` |
| `title` | Что сделать | "Добавить модель User" |
| `description` | Как и зачем | "Создать SQLAlchemy модель..." |
| `type` | Тип | `feature`, `refactor`, `test`, `fix`, `config`, `design`, `docs`, `invariant` |
| `status` | Статус | `pending`, `in_progress`, `completed`, `failed`, `archived` |
| `origin` | Источник | `roadmap:file`, `adr:NNN`, `user:direct`, `agent:analysis`, `decomposition` |
| `files` | Файлы | `["app/models/user.py"]` |
| `depends_on` | Зависимости | `[]` или `["T0"]` |
| `acceptance_criteria` | 3-5 измеримых пунктов | `["Модель проходит миграцию"]` |
| `context` | Доп. информация | `"Смотри app/models/base.py"` |
**Типы origin:**
- `roadmap:{filename}` — из FUTURE/ или roadmap
- `adr:{NNN}` — из Architecture Decision Record
- `user:direct` — от пользователя
- `agent:analysis` — выявлено агентом
- `decomposition` — создано при декомпозиции
- `invariant:{adr_id}` — инвариант для ADR (создаётся командой `/invariant-tests`)
- `risk:{R-NNN}` — из Risk Register
### 4.5. Зелёная декомпозиция (greenfield/scaffold)
Если есть `design-report.md` — задачи на основе группировки из дизайна:
1. **T1: init** — инициализация, зависимости, scaffold.
2. **T2..Tn: features** — модули по одному.
3. **Tn+1: tests** — тесты (можно в составе feature).
4. **Tn+2: polish** — документация, форматирование.
### 4.6. Сортировка
Задачи в манифесте в порядке выполнения:
1. Без зависимостей.
2. Чьи зависимости уже выполнены.
3. С наибольшим числом зависимостей.
### 4.7. Записать manifest
Создать `.agent/tasks/manifest.json` по шаблону `TEMPLATES/task-manifest.json`.
Создать `.agent/tasks/manifest.md` по шаблону `TEMPLATES/task-manifest.md`.
### 4.8. Обновить checkpoints
```json
{
"phases": { "decomposition": "completed" },
"tasks": [...],
"last_updated": "<timestamp>"
}
```
## Выход
- `.agent/tasks/manifest.json`
- `.agent/tasks/manifest.md`
- Обновлённый `checkpoints.json`
## Критерии завершения
- [ ] Цель разбита на атомарные задачи
- [ ] У каждой задачи — acceptance criteria, origin, files
- [ ] Зависимости корректны (нет циклов)
- [ ] Задачи сверены с roadmap (если `sources.md` существует)
- [ ] `manifest.json` и `manifest.md` созданы
- [ ] `checkpoints.json` обновлён
+136
View File
@@ -0,0 +1,136 @@
# Протокол 05: Исполнение задач (EXECUTION)
## Цель
Выполнить задачи из `manifest.json`: реализовать код, написать тесты, закоммитить, создать request — артефакт результата.
EXECUTION — **циклическая** фаза. Работает, пока есть задачи со статусом `pending` и выполненными `depends_on`.
## Вход
- `.agent/tasks/manifest.json`
- `.agent/context/analysis-report.md`
- `.agent/context/design-report.md` (опционально)
- `.agent/decisions/*.md` (опционально)
- `.agent/rules/project-rules.md` — прочитать первым
- `.agent/checkpoints.json` (фаза execution: pending)
## Шаги (цикл)
### 5.1. Прочитать правила проекта
Прочитать `.agent/rules/project-rules.md`, применить.
### 5.2. Setup окружения (первый запуск)
Если это первый запуск EXECUTION в сессии:
- Установить зависимости через штатный пакетный менеджер.
- Запустить сборку / базовые тесты.
- Записать baseline в `.agent/context/baseline-test-report.log`.
### 5.3. Выбрать задачу
Найти в `manifest.json` задачу, удовлетворяющую:
- `status: "pending"`
- Все `depends_on` имеют `status: "completed"` или `"archived"`.
Если таких нет — EXECUTION завершён, перейти к ожиданию команды пользователя.
### 5.4. Заблокировать задачу
В `manifest.json`:
```json
{ "id": "T1", "status": "in_progress" }
```
### 5.5. Исполнить
- Следовать конвенциям проекта (из ANALYSE).
- Соблюдать `BOUNDARIES.md`.
- Если задача ссылается на ADR — следовать архитектурному решению.
- Писать код + тесты.
### 5.6. Верифицировать
- Запустить тесты (все или релевантные).
- Проверить LSP diagnostics на изменённых файлах.
- Убедиться, что acceptance criteria выполнены.
### 5.7. Закоммитить
Сделать git-коммит. Сообщение — суть задачи.
### 5.8. Создать request
Создать `.agent/requests/active/req-{task_id}.json` по шаблону `TEMPLATES/request.json`:
```json
{
"request_id": "req-T1",
"task_id": "T1",
"title": "GET /health endpoint",
"status": "ready_for_review",
"goal": "Добавить ручку GET /health с тестами",
"changes": {
"summary": "Создан health router, подключён в main.py, написаны тесты",
"commits": ["abc1234"],
"files_changed": ["app/routers/health.py", "app/main.py", "tests/test_health.py"]
},
"verification": {
"tests_passed": "24/24",
"lsp_clean": true
},
"fulfills_ac": ["Ручка возвращает 200 + {\"status\":\"ok\"}"]
}
```
Request фиксирует:
- **summary** — суть изменений (не diff).
- **commits** — ссылки на коммиты.
- **files_changed** — какие файлы.
- **verification** — тесты + LSP.
- **fulfills_ac** — какие acceptance criteria закрыты.
### 5.9. Завершить задачу
```json
{ "id": "T1", "status": "completed" }
```
### 5.10. Цикл
Перейти к шагу 5.3. Если задач больше нет — сообщить пользователю и ожидать команду (METASTATE, новая задача, или завершение).
## Request как единица результата
Не просто «задача сделана», а документированный результат. Request проходит ревью в фазе METASTATE:
- `ready_for_review` → после проверки → `approved` или `rejected`.
## Команды во время EXECUTION
В любой момент цикла пользователь может вызвать:
- **/adr** — зафиксировать архитектурное решение, появившееся в процессе.
- **/red-team** — попытаться сломать текущий подход.
- **/risk-register** — зафиксировать новый риск.
Команды не прерывают EXECUTION, но могут добавить задачи в manifest.
## Выход
- Выполненные задачи в `manifest.json` (status: completed)
- `.agent/requests/active/req-{task_id}.json` для каждой выполненной задачи
- Обновлённый `checkpoints.json`
## Критерии завершения (одна итерация)
- [ ] Acceptance criteria выполнены
- [ ] Тесты проходят
- [ ] LSP diagnostics чист
- [ ] Коммит создан
- [ ] Request создан в `.agent/requests/active/`
- [ ] Задача в `manifest.json` отмечена completed
+145
View File
@@ -0,0 +1,145 @@
# Протокол 06: Обновление метасостояния (METASTATE)
## Цель
По команде пользователя провести ревью накопленных requests, синхронизировать манифест, обновить слепок проекта и подготовить `.agent/` как полную картину для следующей сессии.
## Когда запускать
По команде пользователя:
- «обнови метасостояние»
- «update metastate»
- «подведи итог»
- «заверши сессию»
Может запускаться многократно — после каждой группы выполненных задач.
## Вход
- `.agent/requests/active/` — все request-ы со статусом `ready_for_review`
- `.agent/tasks/manifest.json`
- `.agent/context/project-state.md` (создан в ANALYSE, обновляется здесь)
- `.agent/roadmap/sources.md`
- `.agent/decisions/index.json`
- `.agent/checkpoints.json`
## Шаги
### 6.1. Собрать requests
Прочитать все файлы из `.agent/requests/active/` со статусом `ready_for_review`.
### 6.2. Ревью каждого request
Для каждого:
1. **Верифицировать** — тесты проходят, LSP чист, AC выполнены, коммиты на месте.
2. **Принять или отклонить:**
- ✅ **approved**:
- Переместить в `.agent/requests/archive/`.
- В `manifest.json` убедиться: `status: "completed"`.
- ❌ **rejected**:
- Оставить в `active/` с комментарием.
- В `manifest.json`: `status: "reopened"`, добавить `rejection_reason`.
- В request добавить `rejection_reason`.
### 6.3. Архивация завершённых задач
Для каждой `completed` задачи:
1. Создать `.agent/archive/tasks/{id}.json` — полное описание.
2. В `manifest.json` заменить на one-liner:
```json
{ "id": "T1", "title": "GET /health endpoint", "status": "archived", "origin": "user:direct" }
```
### 6.4. Обновить project-state
Переписать `.agent/context/project-state.md` с учётом выполненных задач:
- Обновить список модулей (добавлены / изменены).
- Обновить архитектурную схему (кратко).
- Обновить статус тестов.
- Добавить новые ADR.
- Убрать закрытые concerns.
**Цель:** следующий агент читает `project-state.md` и понимает проект, не открывая исходники.
### 6.5. Обновить roadmap
В `.agent/roadmap/sources.md`:
- Отметить выполненные пункты.
- Пересчитать приоритеты.
- Добавить новые источники (если появились).
### 6.6. Индекс архива
Создать/обновить `.agent/archive/index.json`:
```json
{
"version": "3.0.0",
"archived_at": "<timestamp>",
"tasks": [{ "id": "T1", "title": "...", "archived_at": "<timestamp>" }],
"requests": [{ "id": "req-T1", "task_id": "T1", "archived_at": "<timestamp>" }],
"checkpoints": [{ "file": "checkpoints/<ts>.json", "archived_at": "<timestamp>" }]
}
```
### 6.7. Создать handoff-summary
Создать `.agent/handoff-summary.md` — полная сводка для следующего агента:
```markdown
## Session Summary
**Session:** <id>
**Goal:** <goal>
**Completed:** N tasks
**Pending:** M tasks
**Approved requests:** req-T1, req-T2
## Project State
(краткая выжимка из project-state.md)
## Next Steps
(с чего начать следующую сессию)
## Key Artifacts
- Project state: `.agent/context/project-state.md`
- Tasks: `.agent/tasks/manifest.json`
- Roadmap: `.agent/roadmap/sources.md`
- Pending reviews: `.agent/requests/active/`
- Archive: `.agent/archive/index.json`
```
### 6.8. Обновить checkpoints
```json
{ "phases": { "metastate": "completed" }, "last_updated": "<timestamp>" }
```
## Выход
- `.agent/requests/archive/` — подтверждённые request-ы
- `.agent/archive/tasks/{id}.json` — архив задач
- Обновлённый `.agent/context/project-state.md`
- Обновлённый `.agent/roadmap/sources.md`
- `.agent/handoff-summary.md`
- `.agent/archive/index.json`
- Финальный `checkpoints.json`
## Критерии завершения
- [ ] Все `ready_for_review` requests проверены (approved / rejected)
- [ ] Approved перемещены в archive
- [ ] Completed задачи архивированы (one-liner в manifest)
- [ ] `project-state.md` отражает актуальное состояние
- [ ] `roadmap/sources.md` обновлён
- [ ] `archive/index.json` создан
- [ ] `handoff-summary.md` готов
- [ ] `checkpoints.json` финализирован
+113
View File
@@ -0,0 +1,113 @@
# Протокол 07: Завершение сессии (HANDOFF)
## Цель
Финализация сессии: валидация структуры `.agent/`, финальный `session-summary.md`, отметка `phases.handoff = "completed"`.
> Если перед HANDOFF был METASTATE — архивация, project-state, handoff-summary уже готовы. HANDOFF только валидирует и финализирует.
## Вход
- `.agent/checkpoints.json` (все фазы кроме handoff: completed или skipped)
- Все артефакты `.agent/`
## Шаги
### 7.1. Проверить: был ли METASTATE?
Если существуют `.agent/handoff-summary.md` и `.agent/context/project-state.md` (обновлён) — METASTATE выполнен. Перейти к шагу 7.3.
Если нет — выполнить лёгкую архивацию (шаг 7.2).
### 7.2. Лёгкая архивация (если METASTATE не было)
Если есть `completed` задачи в `manifest.json`:
- Архивировать в `.agent/archive/tasks/{id}.json`.
- Заменить в `manifest.json` на one-liner.
- Создать `.agent/archive/index.json`.
### 7.3. Валидация
Проверить:
- [ ] Все фазы в `checkpoints.json` отмечены `completed` или `skipped`.
- [ ] `.agent/` содержит обязательные файлы:
- `checkpoints.json`
- `context/analysis-report.md`
- `context/project-state.md`
- `tasks/manifest.json` + `manifest.md`
- `rules/project-rules.md`
- `src/GUIDE.md`
- `src/BOUNDARIES.md`
- `src/VERSION`
- `src/PROTOCOLS/`
- `src/COMMANDS/`
- `src/TEMPLATES/`
- [ ] В `manifest.json` нет циклических зависимостей.
- [ ] У каждой задачи — measurable acceptance criteria и origin.
- [ ] `AGENTS.md` присутствует в корне репозитория.
### 7.4. Создать session-summary
Создать `.agent/session-summary.md`:
```markdown
# Session Summary
**Session:** <id>
**MetaAgent version:** 3.0.0
**Date:** <timestamp>
**Goal:** <goal>
## Phases Executed
- [x] INIT
- [x] ANALYSE
- [x] ROADMAP
- [x] DESIGN (или skipped)
- [x] DECOMPOSITION
- [x] EXECUTION (N tasks)
- [x] METASTATE (или skipped)
- [x] HANDOFF
## Results
- Tasks completed: N
- Requests approved: N
- Files changed: [list]
## Next
Следующий агент: читай `.agent/handoff-summary.md`.
```
### 7.5. Финализировать checkpoints
```json
{ "phases": { "handoff": "completed" }, "last_updated": "<timestamp>" }
```
### 7.6. Сигнал
```
HANDOFF COMPLETE
Session: <session_id>
Target: <target_repo>
Type: <existing | greenfield | scaffold>
Tasks: <N> total, <M> completed, <K> pending
Следующий агент начинает с .agent/handoff-summary.md
```
## Выход
- `.agent/session-summary.md`
- Финальный `.agent/checkpoints.json`
- (если METASTATE не было) `.agent/archive/index.json`
## Критерии завершения
- [ ] Все артефакты на месте
- [ ] (если METASTATE не было) `completed` задачи архивированы
- [ ] `session-summary.md` создан
- [ ] `checkpoints.json` финализирован
- [ ] Сигнал отправлен пользователю
+23
View File
@@ -0,0 +1,23 @@
# ADR-NNNN: <Заголовок решения>
**Статус:** proposed | accepted | deprecated | superseded
**Дата:** {{ date }}
**Контекст:** почему возникла необходимость в решении, какая проблема решается.
**Рассматриваемые альтернативы:**
1. Вариант A — описание
2. Вариант B — описание
3. Вариант C — описание
**Решение:** выбран вариант <A/B/C>.
**Обоснование:** почему выбран именно этот вариант (критерии: сложность, поддерживаемость, производительность, совместимость).
**Последствия:**
- Позитивные: ...
- Негативные: ...
- Риски: ...
**Invariant (если применимо):** ключевое правило, которое не должен нарушать исполнительный агент. Если можно — ссылка на тест, проверяющий invariant.
+86
View File
@@ -0,0 +1,86 @@
# Analysis Report
## Session
- **Session ID:** `{{ session_id }}`
- **Target repo:** `{{ target_repo }}`
- **Date:** {{ date }}
- **Project type:** `{{ project_type }}` (existing / greenfield / scaffold)
## 1. Общая информация
- **README:** {{ readme_summary }}
- **Лицензия:** {{ license }}
- **CI/CD:** {{ ci_cd }}
- **Точка входа:** {{ entry_point }}
- **Система сборки:** {{ build_system }}
## 2. Стек технологий (existing / scaffold)
| Компонент | Значение |
|---|---|
| Язык | {{ language }} |
| Фреймворк | {{ framework }} |
| База данных | {{ database }} |
| Тестовый раннер | {{ test_runner }} |
| Пакетный менеджер | {{ package_manager }} |
| Линтер/форматтер | {{ linter }} |
## 3. Архитектура (existing / scaffold)
```
{{ directory_tree }}
```
**Паттерн:** {{ architecture_pattern }}
**Ключевые модули:**
| Модуль | Описание |
|---|---|
| {{ module }} | {{ description }} |
## 4. Конвенции (existing / scaffold)
- **Стиль:** {{ code_style }}
- **Импорты:** {{ import_style }}
- **Типизация:** {{ typing_usage }}
- **Обработка ошибок:** {{ error_handling }}
- **Логирование:** {{ logging }}
## 5. Тесты (existing / scaffold)
- **Команда запуска:** `{{ test_command }}`
- **Всего тестов:** {{ total_tests }}
- **Пройдено:** {{ passed }}
- **Упало:** {{ failed }}
- **Пропущено:** {{ skipped }}
- **Упавшие тесты:** {{ failed_tests_list }}
## 6. Базовая проверка (existing / scaffold)
- **Сборка:** {{ build_status }}
- **Запуск:** {{ run_status }}
- **Git status:** {{ git_status }}
## 7. Требования (greenfield / scaffold)
### Функциональные требования
{{ functional_requirements_list }}
### Нефункциональные требования
{{ non_functional_requirements_list }}
### Бизнес-контекст
{{ business_context_list }}
### Неясные моменты / Вопросы
{{ open_questions_list }}
## 8. Примечания
{{ notes }}
+80
View File
@@ -0,0 +1,80 @@
# Design Report
## Session
- **Session ID:** `{{ session_id }}`
- **Target repo:** `{{ target_repo }}`
- **Date:** {{ date }}
## 1. Технологический стек
| Компонент | Выбор | Обоснование |
|---|---|---|
| Язык | {{ language }} | {{ language_rationale }} |
| Фреймворк | {{ framework }} | {{ framework_rationale }} |
| База данных | {{ database }} | {{ database_rationale }} |
| Инфраструктура | {{ infrastructure }} | {{ infrastructure_rationale }} |
## 2. High-Level архитектура
**Паттерн:** {{ architecture_pattern }}
```
{{ architecture_diagram }}
```
**Поток данных:**
1. {{ data_flow_step_1 }}
2. {{ data_flow_step_2 }}
3. {{ data_flow_step_3 }}
## 3. Модули
| Модуль | Ответственность | Ключевые компоненты | Зависит от |
|---|---|---|---|
| `{{ module_path }}` | {{ responsibility }} | {{ components }} | {{ dependencies }} |
## 4. Модели данных
### Сущности
{{ entity_descriptions }}
## 5. API / Интерфейсы
{{ api_endpoints_table }}
## 6. Обработка ошибок
- **Стратегия:** {{ error_strategy }}
- **Формат ошибок:** {{ error_format }}
- **Логирование:** {{ logging_strategy }}
## 7. Тестирование
- **Unit-тесты:** {{ unit_test_strategy }}
- **Integration-тесты:** {{ integration_test_strategy }}
- **Mock-стратегия:** {{ mock_strategy }}
- **Команда запуска:** `{{ test_command }}`
## 8. Дополнительные артефакты (по команде пользователя)
Если пользователь вызвал соответствующие команды, добавить ссылки:
- ADR: `.agent/decisions/` (команда `/adr`)
- Alternative Architecture: `.agent/context/alt-architecture.md` (команда `/alt-arch`)
- Risk Register: `.agent/context/risk-register.md` (команда `/risk-register`)
- Red Team Review: `.agent/context/red-team-report.md` (команда `/red-team`)
## 9. Предварительная группировка задач
| Задача | Описание | Тип |
|---|---|---|
| T1 | {{ task_1 }} | config |
| T2 | {{ task_2 }} | feature |
| T3 | {{ task_3 }} | feature |
| T4 | {{ task_4 }} | test |
## 10. Примечания
{{ notes }}
+57
View File
@@ -0,0 +1,57 @@
# Handoff Summary
## Session Info
- **Session ID:** `{{ session_id }}`
- **Target Repo:** {{ target_repo }}
- **Goal:** {{ goal }}
- **Date:** {{ date }}
- **Project type:** {{ project_type }}
## Repo Summary
{{ repo_summary }}
## Artifacts Created
- **Analysis report:** `.agent/context/analysis-report.md`
- **Project state:** `.agent/context/project-state.md`
- **Design report:** {{ design_report_path_or_dash }}
- **Roadmap:** `.agent/roadmap/sources.md`
- **ADR:** {{ adr_summary_or_dash }}
- **Risk Register:** {{ risk_register_path_or_dash }}
- **Red Team Report:** {{ red_team_report_path_or_dash }}
## Environment Status
- **Build:** {{ build_status }}
- **Tests:** {{ tests_passed }}/{{ tests_total }} passed
- **Baseline log:** `.agent/context/baseline-test-report.log`
- **Dependencies:** {{ deps_status }}
## Task Overview
| Status | Count |
|---|---|
| Total | {{ total }} |
| Pending | {{ pending }} |
| In Progress | {{ in_progress }} |
| Completed | {{ completed }} |
| Archived | {{ archived }} |
| Failed/Skipped | {{ failed }} |
## Tasks (ordered)
{{ task_list_markdown }}
## Next Steps
Следующий агент: прочитай `.agent/context/project-state.md`, затем `.agent/tasks/manifest.json` и приступай к первой `pending` задаче.
## Caveats
{{ caveats_list }}
## Checkpoints
Файл: `.agent/checkpoints.json` — состояние фаз и список задач.
+17
View File
@@ -0,0 +1,17 @@
# Project Rules
Правила, которым агент обязан следовать во всех фазах.
Добавляйте сюда условия, которые должны соблюдаться всегда — они будут прочитаны
перед началом каждой фазы и учтены при декомпозиции и реализации.
## Обязательные правила
- (укажите правила, например: «Всегда использовать tabs для отступов»)
## Запреты
- (укажите запреты, например: «Не трогать CI/CD конфигурацию»)
## Конвенции проекта
- (укажите конвенции, например: «Имена классов в PascalCase, функции в snake_case»)
+43
View File
@@ -0,0 +1,43 @@
# Project State
# Auto-generated — updated by ANALYSE (initial) and METASTATE (on updates)
**Last updated:** {{ timestamp }}
**Session:** {{ session_id }}
## Project Type
{{ project_type }}
## Tech Stack
| Category | Technology |
|----------|-----------|
| Language | {{ language }} |
| Framework | {{ framework }} |
| Database | {{ database }} |
| Test runner | {{ test_runner }} |
| Package manager | {{ package_manager }} |
## Current Architecture
{{ architecture_description }}
## Key Modules
| Module | Status | Description |
|--------|--------|-------------|
| {{ module_name }} | {{ existing / stub / new }} | {{ module_description }} |
## Decisions in Effect
| ADR | Decision | Status |
|-----|----------|--------|
| {{ adr_id }} | {{ decision_summary }} | {{ active / superseded }} |
## Testing Status
{{ testing_summary }}
## Open Concerns
- {{ concern_1 }}
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": ".agent/src/TEMPLATES/schemas/request-schema.json",
"template_version": "1.0",
"request_id": "req-{{ task_id }}",
"task_id": "{{ task_id }}",
"title": "{{ task_title }}",
"status": "ready_for_review",
"created_at": "{{ timestamp }}",
"goal": "{{ task_goal }}",
"changes": {
"summary": "{{ changes_summary }}",
"commits": [
"{{ commit_hash }}"
],
"files_changed": [
"path/to/file.py"
]
},
"verification": {
"tests_passed": "{{ test_results }}",
"lsp_clean": true
},
"fulfills_ac": [
"{{ acceptance_criterion }}"
]
}
+7
View File
@@ -0,0 +1,7 @@
# Risk Register
| # | Assumption | Impact if wrong | Mitigation | Review trigger |
|---|---|---|---|---|
| R1 | Пользователи имеют Python 3.11+ | Проект не запускается на старых версиях | Указать требование в README, CI-проверка | При жалобе на установку |
| R2 | JSON-файлы не превышают 10MB | Деградация производительности | Добавить лимит в model.py | При первом замедлении |
| R3 | ... | ... | ... | ... |
+42
View File
@@ -0,0 +1,42 @@
# Roadmap Sources
# Auto-generated — created by ROADMAP phase
**Created:** {{ timestamp }}
**Session:** {{ session_id }}
## Priority Legend
- **P0** — Critical, do next
- **P1** — Important, do soon
- **P2** — Nice to have
- **P3** — Future / deferred
## Sources
### FUTURE Plans
| Plan | Priority | Status | Origin File |
|------|----------|--------|-------------|
| {{ plan_title }} | {{ P0-P3 }} | {{ active / archived }} | FUTURE/{{ filename }}.md |
### ADR-Derived Tasks
| Source ADR | Task | Priority |
|------------|------|----------|
| {{ adr_id }} | {{ task_description }} | {{ P0-P3 }} |
### User Requests
| Request | Priority | Source |
|---------|----------|--------|
| {{ request }} | {{ P0-P3 }} | {{ direct / issue / feedback }} |
### Agent-Identified Improvements
| Observation | Suggested Task | Priority |
|-------------|----------------|----------|
| {{ observation }} | {{ task }} | {{ P0-P3 }} |
## Consolidated Priority Queue
1. **{{ task_title }}** ({{ origin }}) — {{ priority }}
@@ -0,0 +1,66 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "metaagent/checkpoints/3.0.0",
"title": "MetaAgent Checkpoints",
"description": "Schema for .agent/checkpoints.json — session state",
"type": "object",
"properties": {
"metaagent_version": {
"type": "string",
"description": "MetaAgent version that created this checkpoint",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"session_id": {
"type": "string",
"description": "Unique session identifier"
},
"target_repo": {
"type": "string",
"description": "Path to the target repository"
},
"goal": {
"type": ["string", "null"],
"description": "Session goal (set by user, may be null until first task)"
},
"project_type": {
"type": ["string", "null"],
"enum": [null, "existing", "greenfield", "scaffold"],
"description": "Type of the target project (set in ANALYSE phase)"
},
"phases": {
"type": "object",
"properties": {
"init": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
"analyse": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
"roadmap": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
"design": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
"decomposition": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
"execution": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
"metastate": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] },
"handoff": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "skipped"] }
},
"additionalProperties": false
},
"tasks": {
"type": "array",
"description": "List of tasks (one-liners after archiving)",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"status": { "type": "string", "enum": ["pending", "in_progress", "completed", "failed", "archived"] }
},
"required": ["id", "title", "status"],
"additionalProperties": false
}
},
"last_updated": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp of last update"
}
},
"required": ["metaagent_version", "session_id", "phases", "last_updated"],
"additionalProperties": false
}
@@ -0,0 +1,60 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "metaagent/decisions-index/3.0.0",
"title": "MetaAgent Decisions Index",
"description": "Schema for .agent/decisions/index.json — machine-readable index of ADRs",
"type": "object",
"properties": {
"version": {
"type": "string",
"description": "Schema version (3.0 in v3.0+, 2.0 still valid from v2.1)",
"enum": ["2.0", "3.0"]
},
"decisions": {
"type": "array",
"description": "List of architecture decision records",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^[0-9]{3,}$",
"description": "ADR number (001, 002, ...)"
},
"title": {
"type": "string",
"description": "Short title of the decision"
},
"status": {
"type": "string",
"enum": ["proposed", "accepted", "deprecated", "superseded"],
"description": "ADR status"
},
"file": {
"type": "string",
"description": "Filename in .agent/decisions/ (e.g. 001-stack.md)"
},
"date": {
"type": "string",
"format": "date",
"description": "Decision date (ISO 8601)"
}
},
"required": ["id", "title", "file"],
"additionalProperties": false
}
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp of index creation"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp of last update"
}
},
"required": ["version", "decisions"],
"additionalProperties": false
}
@@ -0,0 +1,95 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "metaagent/task-manifest/3.0.0",
"title": "MetaAgent Task Manifest",
"description": "Schema for .agent/tasks/manifest.json — the global task manifest",
"type": "object",
"properties": {
"version": {
"type": "string",
"description": "Schema version",
"enum": ["3.0"]
},
"session_id": {
"type": "string",
"description": "Session ID that created this manifest"
},
"goal": {
"type": "string",
"description": "Overall goal of the session"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "ISO 8601 timestamp of creation"
},
"tasks": {
"type": "array",
"description": "List of tasks",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^(T[0-9]+|T-INV-[0-9]+)$",
"description": "Unique task identifier (T1, T2, ... or T-INV-N for invariants)"
},
"title": {
"type": "string",
"description": "Task title"
},
"description": {
"type": "string",
"description": "Detailed description"
},
"type": {
"type": "string",
"enum": ["feature", "refactor", "test", "fix", "config", "design", "docs", "invariant"],
"description": "Task type"
},
"origin": {
"type": "string",
"description": "Task source (roadmap:file, adr:NNN, user:direct, agent:analysis, decomposition, invariant:NNN, risk:R-NNN)"
},
"files": {
"type": "array",
"items": { "type": "string" },
"description": "Files affected by this task"
},
"depends_on": {
"type": "array",
"items": { "type": "string" },
"description": "Task IDs this task depends on"
},
"acceptance_criteria": {
"type": "array",
"items": { "type": "string" },
"description": "Measurable criteria for completion"
},
"context": {
"type": "string",
"description": "Additional context or references"
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed", "failed", "archived"],
"description": "Current task status"
},
"claimed_by": {
"type": "string",
"description": "Worker ID if claimed"
},
"claimed_at": {
"type": "string",
"format": "date-time",
"description": "When the task was claimed"
}
},
"required": ["id", "title", "type", "status"],
"additionalProperties": false
}
}
},
"required": ["version", "tasks"],
"additionalProperties": false
}
+50
View File
@@ -0,0 +1,50 @@
# Session Summary
**Session:** {{ session_id }}
**Target:** {{ target_repo }}
**MetaAgent version:** {{ version }}
**Date:** {{ date }}
## Phase Status
| Phase | Status |
|---|---|
| INIT | {{ init_status }} |
| ANALYSE | {{ analyse_status }} |
| ROADMAP | {{ roadmap_status }} |
| DESIGN | {{ design_status }} |
| DECOMPOSITION | {{ decomposition_status }} |
| EXECUTION | {{ execution_status }} |
| METASTATE | {{ metastate_status }} |
| HANDOFF | {{ handoff_status }} |
## Tasks
| Status | Count |
|---|---|
| Total | {{ total }} |
| Pending | {{ pending }} |
| In Progress | {{ in_progress }} |
| Completed | {{ completed }} |
| Archived | {{ archived }} |
| Failed/Skipped | {{ failed }} |
**By origin:**
- user:direct: {{ user_direct_count }}
- roadmap: {{ roadmap_count }}
- adr: {{ adr_count }}
- decomposition: {{ decomposition_count }}
- (другое): {{ other_count }}
## Commands Invoked (если были)
{{ commands_invoked_list }}
## Quick Links
- Task Manifest: `.agent/tasks/manifest.json`
- Handoff Summary: `.agent/handoff-summary.md`
- Project State: `.agent/context/project-state.md`
- ADR: `.agent/decisions/`
- Risk Register: `.agent/context/risk-register.md`
- Red Team Report: `.agent/context/red-team-report.md`
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": ".agent/src/TEMPLATES/schemas/task-manifest-schema.json",
"version": "3.0",
"session_id": "{{ session_id }}",
"goal": "{{ goal }}",
"created_at": "{{ timestamp }}",
"tasks": [
{
"id": "T1",
"title": "{{ task_title }}",
"description": "{{ task_description }}",
"type": "feature|refactor|test|fix|config|design|docs|invariant",
"origin": "user:direct",
"files": ["path/to/file1.py", "path/to/file2.py"],
"depends_on": [],
"acceptance_criteria": [
"Критерий 1: ...",
"Критерий 2: ..."
],
"context": "Дополнительная информация",
"status": "pending"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
# Task Manifest
**Session:** {{ session_id }}
**Goal:** {{ goal }}
**Date:** {{ timestamp }}
---
## Task Overview
| ID | Title | Type | Depends On | Status |
|---|---|---|---|---|
| T1 | {{ title }} | {{ type }} | — | pending |
| T2 | {{ title }} | {{ type }} | T1 | pending |
**Total tasks:** {{ count }}
---
## Task Details
### T1: {{ title }}
**Type:** {{ type }}
**Description:** {{ description }}
**Files:**
- `{{ file_path }}`
**Depends on:** —
**Acceptance Criteria:**
- [ ] {{ criterion }}
- [ ] {{ criterion }}
**Context:** {{ context }}
---
### T2: {{ title }}
...
+1
View File
@@ -0,0 +1 @@
3.0.0
+240
View File
@@ -0,0 +1,240 @@
# WORKFLOW — Сквозной пример сессии v3.0
---
## Сценарий: рефакторинг auth-модуля
**Цель:** Вынести логику из `auth/login.py` (450 строк, монолит) в отдельные модули `auth/router.py`, `auth/schemas.py`, `auth/deps.py`.
**Целевой репозиторий:** `github.com/example/fastapi-app`
**Пользователь:** «Вынеси авторизацию в отдельные модули».
**MetaAgent:** v3.0.0
---
### PROJECT LOOP
#### INIT
Агент читает `AGENTS.md`, переходит в `.agent/src/GUIDE.md`. Понимает цикл. Создаёт `.agent/`, копирует исходники, инициализирует `checkpoints.json`:
```json
{
"metaagent_version": "3.0.0",
"session_id": "ses_v30_001",
"target_repo": "/tmp/fastapi-app",
"goal": "Вынести авторизацию в auth/{router,schemas,deps}.py",
"project_type": null,
"phases": {
"init": "completed",
"analyse": "pending",
"roadmap": "pending",
"design": "pending",
"decomposition": "pending",
"execution": "pending",
"metastate": "pending",
"handoff": "pending"
},
"tasks": [],
"last_updated": "2026-10-08T15:00:00Z"
}
```
#### ANALYSE
Агент сканирует проект:
- Стек: Python 3.12, FastAPI, SQLAlchemy, pytest.
- `auth/login.py` — 450 строк, монолит (цель рефакторинга).
- Тесты: 48 passed (baseline).
Создаёт:
- `.agent/context/analysis-report.md`
- `.agent/context/project-state.md` (начальный)
`checkpoints.json`: `project_type = "existing"`, `phases.analyse = "completed"`.
#### ROADMAP
- `FUTURE/` — пусто.
- `.agent/decisions/` — пусто.
- Единственный источник — пользовательский запрос.
Создаёт `.agent/roadmap/sources.md`:
```markdown
## User Requests
| Вынести авторизацию | P0 | user:direct |
## Consolidated Priority Queue
1. Вынести auth/ (user:direct) — P0
```
`phases.roadmap = "completed"`.
#### DESIGN
**Пропускается** (existing-проект). `phases.design = "skipped"`.
#### DECOMPOSITION
Задачи:
```json
{
"tasks": [
{
"id": "T1",
"title": "Создать auth/router.py",
"origin": "user:direct",
"files": ["app/auth/router.py"],
"depends_on": [],
"acceptance_criteria": [
"Роуты авторизации вынесены из auth/login.py",
"auth/router.py экспортирует router",
"Существующие тесты проходят"
],
"status": "pending"
},
{
"id": "T2",
"title": "Создать auth/schemas.py",
"origin": "user:direct",
"files": ["app/auth/schemas.py"],
"depends_on": ["T1"],
"acceptance_criteria": [
"Pydantic схемы вынесены в auth/schemas.py",
"Существующие тесты проходят"
],
"status": "pending"
},
{
"id": "T3",
"title": "Создать auth/deps.py",
"origin": "user:direct",
"files": ["app/auth/deps.py"],
"depends_on": ["T1"],
"acceptance_criteria": [
"Dependency injection функции вынесены в auth/deps.py",
"Существующие тесты проходят"
],
"status": "pending"
}
]
}
```
`phases.decomposition = "completed"`.
---
### WORK LOOP (первая итерация)
#### EXECUTION — задача T1
1. Берёт T1 (`pending`, нет зависимостей).
2. `status = "in_progress"`.
3. Создаёт `app/auth/router.py` — переносит роуты.
4. Тесты: 48/48.
5. Коммит: `abc1234 — refactor: extract auth router`.
6. Создаёт request:
```json
{
"request_id": "req-T1",
"task_id": "T1",
"title": "Создать auth/router.py",
"status": "ready_for_review",
"goal": "Вынести роуты авторизации",
"changes": {
"summary": "Роуты авторизации вынесены из auth/login.py в auth/router.py",
"commits": ["abc1234"],
"files_changed": ["app/auth/router.py", "app/auth/__init__.py", "tests/test_auth_router.py"]
},
"verification": { "tests_passed": "48/48", "lsp_clean": true },
"fulfills_ac": ["Роуты вынесены", "Тесты проходят"]
}
```
7. `T1 → completed`.
#### EXECUTION — задача T2
Создаёт `auth/schemas.py`, request `req-T2`. T2 → completed.
#### EXECUTION — задача T3
Создаёт `auth/deps.py`, request `req-T3`. T3 → completed.
Задачи закончились. Агент ждёт команду.
---
### METASTATE (по команде пользователя)
**Пользователь:** «обнови метасостояние».
1. **Ревью requests:** три request-а, все approved.
- `req-T1`, `req-T2`, `req-T3` → `.agent/requests/archive/`.
2. **Архивация задач:**
- T1, T2, T3 → `.agent/archive/tasks/`.
- В `manifest.json` — one-liner: `status: "archived"`.
3. **Обновление project-state.md:**
```markdown
## Key Modules
| Module | Status | Description |
|--------|--------|-------------|
| app/auth/router.py | new | Вынесенные роуты |
| app/auth/schemas.py | new | Pydantic схемы |
| app/auth/deps.py | new | Dependency injection |
```
4. **Создание handoff-summary.md:**
```markdown
## Session Summary
**Goal:** Рефакторинг авторизации
**Completed:** 3/3 tasks
**Approved requests:** req-T1, req-T2, req-T3
## Project State
auth разбит на router + schemas + deps.
Исходный auth/login.py: 450 → 120 строк.
## Next Steps
- Проверить, не осталось ли прямых импортов из старого login.py
- Обновить main.py если нужно
```
---
### HANDOFF
```
HANDOFF COMPLETE
Session: ses_v30_001
Target: /tmp/fastapi-app
Type: existing
Tasks: 3/3 completed
Следующий агент начинает с .agent/handoff-summary.md
```
---
## Использование команд в процессе
В любой момент сессии пользователь мог вызвать:
- **«запиши это как ADR»** → `COMMANDS/adr.md` создал бы `.agent/decisions/001-modular-auth.md`.
- **«red team»** → `COMMANDS/red-team.md` создал бы `.agent/context/red-team-report.md` с попыткой сломать новую структуру.
- **«risk register»** → `COMMANDS/risk-register.md` зафиксировал бы допущения (например, «считаем, что порядок middleware не важен»).
Команды **не обязательны**. Если не вызваны — `.agent/decisions/`, `risk-register.md`, `red-team-report.md` не создаются.
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env pwsh
# MetaAgent — установка исходников в целевой проект
# Usage: .\install.ps1 [[-Path] target_path] [-Check] [-Update]
param(
[string]$Path = "",
[switch]$Check,
[switch]$Update,
[switch]$Help
)
$MetaAgentSrc = Split-Path -Parent $MyInvocation.MyCommand.Path
# --- helpers ---
function Write-Info { Write-Host " →" -NoNewline -ForegroundColor Blue; Write-Host " $args" }
function Write-Ok { Write-Host " ✓" -NoNewline -ForegroundColor Green; Write-Host " $args" }
function Write-Skip { Write-Host " −" -NoNewline -ForegroundColor Yellow; Write-Host " $args" }
function Write-Warn { Write-Host " ⚠" -NoNewline -ForegroundColor Yellow; Write-Host " $args" }
function Write-Fail { Write-Host " ✗" -NoNewline -ForegroundColor Red; Write-Host " $args" }
function Write-Header { param([string]$Label)
Write-Host ""
Write-Host ("─" * 40)
Write-Host " $Label"
Write-Host ("─" * 40)
}
function Show-Usage {
@"
Usage: install.ps1 [[-Path] target_path] [-Check] [-Update] [-Help]
Install MetaAgent sources into <target>/.agent/src/
Options:
-Path Path to target project (default: interactive prompt)
-Check Dry-run: only check target readiness, no install
-Update Overwrite existing files in .agent/src/
-Help Show this help
Examples:
.\install.ps1
.\install.ps1 -Path C:\Projects\MyApp
.\install.ps1 -Path C:\Projects\MyApp -Check
.\install.ps1 -Path C:\Projects\MyApp -Update
"@
exit 0
}
if ($Help) { Show-Usage }
# --- resolve target ---
$TargetPath = $Path
if (-not $TargetPath) {
$TargetPath = Read-Host "Enter path to target project"
}
$TargetPath = $TargetPath.Trim()
# --- pre-flight -----------------------------------------------------------
Write-Header "Pre-flight"
# 1. target exists?
if (-not (Test-Path $TargetPath -PathType Container)) {
Write-Fail "Target directory '$TargetPath' does not exist."
exit 1
}
$TargetPath = (Resolve-Path $TargetPath).Path
Write-Ok "Target: $TargetPath"
# 2. write permission? (try to create a temp file as probe)
$probe = [System.IO.Path]::Combine($TargetPath, ".metaagent_probe.tmp")
try {
[System.IO.File]::WriteAllBytes($probe, [byte[]]@())
Remove-Item $probe -Force
Write-Ok "Write permission: yes"
} catch {
Write-Fail "No write permission on '$TargetPath'."
exit 1
}
# 3. already installed? compare versions
$AgentDir = Join-Path $TargetPath ".agent"
$SrcDir = Join-Path $AgentDir "src"
$VersionFile = Join-Path $MetaAgentSrc "VERSION"
$Version = if (Test-Path $VersionFile -PathType Leaf) {
(Get-Content $VersionFile -Raw -Encoding UTF8).Trim()
} else { "?" }
$oldVerPath = Join-Path $SrcDir "VERSION"
if (Test-Path $oldVerPath -PathType Leaf) {
$oldVer = (Get-Content $oldVerPath -Raw -Encoding UTF8).Trim()
if ($oldVer -ne $Version) {
Write-Info "Existing MetaAgent v$oldVer found → upgrading to v$Version"
} else {
Write-Skip "MetaAgent v${Version} already installed (use -Update to reinstall)"
if (-not $Check) {
Write-Warn "No changes applied. Run with -Update to overwrite existing files."
}
}
} else {
Write-Info "Fresh install: MetaAgent v$Version"
}
# 4. summary
$AgentsMd = Join-Path $TargetPath "AGENTS.md"
$RulesDir = Join-Path $AgentDir "rules"
$DecisionsDir = Join-Path $AgentDir "decisions"
$TasksDir = Join-Path $AgentDir "tasks"
$ContextDir = Join-Path $AgentDir "context"
$ArchiveDir = Join-Path $AgentDir "archive"
$RequestsDir = Join-Path $AgentDir "requests"
$RoadmapDir = Join-Path $AgentDir "roadmap"
$TempDir = Join-Path $TargetPath ".temp"
$DirList = @(
$SrcDir, $RulesDir, $DecisionsDir, $TasksDir,
(Join-Path $TasksDir "backlog"), $ContextDir,
$ArchiveDir,
(Join-Path $ArchiveDir "tasks"),
(Join-Path $ArchiveDir "decisions"),
(Join-Path $ArchiveDir "checkpoints"),
(Join-Path $RequestsDir "active"),
(Join-Path $RequestsDir "archive"),
$RoadmapDir,
(Join-Path $RoadmapDir "archive"),
$TempDir
)
if ($Check) {
Write-Host ""
Write-Info "--check mode: all checks passed, no changes applied."
exit 0
}
# --- phase 1: directories ------------------------------------------------
Write-Header "Directories"
foreach ($d in $DirList) {
$null = New-Item -ItemType Directory -Path $d -Force
$short = $d.Replace("$TargetPath\", "")
if (Test-Path $d -PathType Container) {
Write-Ok $short
} else {
Write-Fail "$short (creation failed)"
}
}
# --- phase 2: files ------------------------------------------------------
Write-Header "Files"
$copyCount = 0
$skipCount = 0
$failCount = 0
function Copy-File {
param([string]$Src, [string]$DstDir)
$name = Split-Path $Src -Leaf
$dst = Join-Path $DstDir $name
if (-not (Test-Path $Src -PathType Leaf)) {
Write-Skip "$name (source not found)"
$script:skipCount++
return
}
if ($Update -or -not (Test-Path $dst)) {
try {
Copy-Item $Src $dst -Force -ErrorAction Stop
Write-Ok $name
$script:copyCount++
} catch {
Write-Fail $name
$script:failCount++
}
} else {
Write-Skip "$name (exists, use -Update to overwrite)"
$script:skipCount++
}
}
function Copy-Dir {
param([string]$Src, [string]$DstDir)
$name = Split-Path $Src -Leaf
$dst = Join-Path $DstDir $name
if (-not (Test-Path $Src -PathType Container)) {
Write-Skip "$name/ (source not found)"
$script:skipCount++
return
}
$null = New-Item -ItemType Directory -Path $dst -Force
try {
if ($Update) {
Get-ChildItem $Src | ForEach-Object {
Copy-Item $_.FullName $dst -Recurse -Force -ErrorAction Stop
}
} else {
Get-ChildItem $Src | ForEach-Object {
$targetPath = Join-Path $dst $_.Name
if (-not (Test-Path $targetPath)) {
Copy-Item $_.FullName $dst -Recurse -ErrorAction Stop
}
}
}
Write-Ok "$name/"
$script:copyCount++
} catch {
Write-Fail "$name/ (partial copy)"
$script:failCount++
}
}
Copy-File (Join-Path $MetaAgentSrc "GUIDE.md") $SrcDir
Copy-File (Join-Path $MetaAgentSrc "BOUNDARIES.md") $SrcDir
Copy-File (Join-Path $MetaAgentSrc "CHANGELOG.md") $SrcDir
Copy-File (Join-Path $MetaAgentSrc "WORKFLOW.md") $SrcDir
Copy-File (Join-Path $MetaAgentSrc "VERSION") $SrcDir
Copy-Dir (Join-Path $MetaAgentSrc "PROTOCOLS") $SrcDir
Copy-Dir (Join-Path $MetaAgentSrc "COMMANDS") $SrcDir
Copy-Dir (Join-Path $MetaAgentSrc "TEMPLATES") $SrcDir
Copy-File (Join-Path $MetaAgentSrc "install.sh") $SrcDir
Copy-File (Join-Path $MetaAgentSrc "install.ps1") $SrcDir
# --- phase 3: AGENTS.md --------------------------------------------------
Write-Header "AGENTS.md"
if (-not (Test-Path $AgentsMd -PathType Leaf)) {
$content = @"
# MetaAgent
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v$Version —
набор инструкций для AI-агента.
## Контекст MetaAgent
| Ресурс | Путь |
|--------|------|
| Главная инструкция | `.agent/src/GUIDE.md` |
| Протоколы фаз | `.agent/src/PROTOCOLS/` |
| Команды (on-demand) | `.agent/src/COMMANDS/` |
| Шаблоны артефактов | `.agent/src/TEMPLATES/` |
| Границы (что разрешено/запрещено) | `.agent/src/BOUNDARIES.md` |
| История версий | `.agent/src/CHANGELOG.md` |
| Правила проекта | `.agent/rules/project-rules.md` |
| Пример работы | `.agent/src/WORKFLOW.md` |
| Версия | `.agent/src/VERSION` |
## Состояние сессии (если инициализировано)
| Артефакт | Путь |
|----------|------|
| Чекпоинты сессии | `.agent/checkpoints.json` |
| Слепок проекта | `.agent/context/project-state.md` |
| Анализ репозитория | `.agent/context/analysis-report.md` |
| Дорожная карта | `.agent/roadmap/sources.md` |
| Манифест задач | `.agent/tasks/manifest.json` |
| Сводка для следующего агента | `.agent/handoff-summary.md` |
| Сводка сессии | `.agent/session-summary.md` |
## Для агента
Жизненный цикл MetaAgent v$Version:
```
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
```
1. **Прочитай** `.agent/src/GUIDE.md` — пойми цикл и доступные команды.
2. **Прочитай** `.agent/src/BOUNDARIES.md` — соблюдай границы.
3. **Прочитай** `.agent/rules/project-rules.md` — выполни правила пользователя.
4. **Проверь** `.agent/checkpoints.json` — если существует, используй как состояние сессии.
5. **Проверь** `.agent/context/project-state.md` — получи актуальную картину.
6. **Проверь** `.agent/tasks/manifest.json` — если существует, выполняй задачи по порядку.
7. Если `.agent/` не инициализирован или устарел — запусти `install.sh --update` для
обновления исходников MetaAgent до актуальной версии.
## Команды (on-demand)
В любой момент пользователь может вызвать:
- `/adr` — записать архитектурное решение
- `/red-team` — попытаться сломать дизайн
- `/risk-register` — зафиксировать допущения
- `/alt-arch` — описать альтернативу
- `/invariant-tests` — тесты-инварианты для ADR
"@
$utf8 = [System.Text.Encoding]::UTF8
[System.IO.File]::WriteAllBytes($AgentsMd, $utf8.GetBytes($content))
Write-Ok "AGENTS.md created"
} elseif ($Update) {
$content = @"
# MetaAgent
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v$Version —
набор инструкций для AI-агента.
## Контекст MetaAgent
| Ресурс | Путь |
|--------|------|
| Главная инструкция | `.agent/src/GUIDE.md` |
| Протоколы фаз | `.agent/src/PROTOCOLS/` |
| Команды (on-demand) | `.agent/src/COMMANDS/` |
| Шаблоны артефактов | `.agent/src/TEMPLATES/` |
| Границы (что разрешено/запрещено) | `.agent/src/BOUNDARIES.md` |
| История версий | `.agent/src/CHANGELOG.md` |
| Правила проекта | `.agent/rules/project-rules.md` |
| Пример работы | `.agent/src/WORKFLOW.md` |
| Версия | `.agent/src/VERSION` |
## Состояние сессии (если инициализировано)
| Артефакт | Путь |
|----------|------|
| Чекпоинты сессии | `.agent/checkpoints.json` |
| Слепок проекта | `.agent/context/project-state.md` |
| Анализ репозитория | `.agent/context/analysis-report.md` |
| Дорожная карта | `.agent/roadmap/sources.md` |
| Манифест задач | `.agent/tasks/manifest.json` |
| Сводка для следующего агента | `.agent/handoff-summary.md` |
| Сводка сессии | `.agent/session-summary.md` |
## Для агента
Жизненный цикл MetaAgent v$Version:
```
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
```
1. **Прочитай** `.agent/src/GUIDE.md` — пойми цикл и доступные команды.
2. **Прочитай** `.agent/src/BOUNDARIES.md` — соблюдай границы.
3. **Прочитай** `.agent/rules/project-rules.md` — выполни правила пользователя.
4. **Проверь** `.agent/checkpoints.json` — если существует, используй как состояние сессии.
5. **Проверь** `.agent/context/project-state.md` — получи актуальную картину.
6. **Проверь** `.agent/tasks/manifest.json` — если существует, выполняй задачи по порядку.
7. Если `.agent/` не инициализирован или устарел — запусти `install.sh --update` для
обновления исходников MetaAgent до актуальной версии.
## Команды (on-demand)
В любой момент пользователь может вызвать:
- `/adr` — записать архитектурное решение
- `/red-team` — попытаться сломать дизайн
- `/risk-register` — зафиксировать допущения
- `/alt-arch` — описать альтернативу
- `/invariant-tests` — тесты-инварианты для ADR
"@
$utf8 = [System.Text.Encoding]::UTF8
[System.IO.File]::WriteAllBytes($AgentsMd, $utf8.GetBytes($content))
Write-Ok "AGENTS.md updated"
} else {
Write-Skip "AGENTS.md (exists, use -Update to overwrite)"
$script:skipCount++
}
# --- summary -------------------------------------------------------------
Write-Header "Summary"
Write-Host " MetaAgent v$Version → $SrcDir"
Write-Host ""
if ($copyCount -gt 0) { Write-Ok "$copyCount file(s) copied" }
if ($skipCount -gt 0) { Write-Skip "$skipCount file(s) skipped" }
if ($failCount -gt 0) { Write-Fail "$failCount file(s) failed" }
Write-Host ""
if ($failCount -eq 0) {
Write-Ok "Installation completed successfully."
} else {
Write-Fail "Installation completed with $failCount error(s)."
exit 1
}
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env bash
# MetaAgent — установка исходников в целевой проект
# Usage: ./install.sh [--check|--update] [target_path]
set -euo pipefail
METAAGENT_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- helpers ---
red=; grn=; ylw=; blu=; rst=
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
red=$(tput setaf 1); grn=$(tput setaf 2)
ylw=$(tput setaf 3); blu=$(tput setaf 4)
rst=$(tput sgr0)
fi
info() { echo " ${blu}→${rst} $*"; }
ok() { echo " ${grn}✓${rst} $*"; }
skip() { echo " ${ylw}−${rst} $*"; }
warn() { echo " ${ylw}⚠${rst} $*"; }
fail() { echo " ${red}✗${rst} $*"; }
header(){ echo; echo "────────────────────────────────────────"; echo " $*"; echo "────────────────────────────────────────"; }
usage() {
cat <<EOF
Usage: $0 [--check|--update] [target_path]
Install MetaAgent sources into <target>/.agent/src/
Options:
--check, -c Dry-run: only check target readiness, no install
--update, -u Overwrite existing files in .agent/src/
--help, -h Show this help
Examples:
$0
$0 /path/to/project
$0 --check /path/to/project
$0 --update /path/to/project
EOF
exit 0
}
# --- arg parsing ---
CHECK=false
UPDATE=false
TARGET_PATH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--check|-c) CHECK=true; shift ;;
--update|-u) UPDATE=true; shift ;;
--help|-h) usage ;;
--*) echo "${red}Unknown option:${rst} $1"; usage ;;
*) TARGET_PATH="$1"; shift ;;
esac
done
# --- resolve target ---
if [[ -z "$TARGET_PATH" ]]; then
read -r -p "Enter path to target project: " TARGET_PATH
fi
TARGET_PATH="${TARGET_PATH/#\~/$HOME}"
# --- pre-flight -----------------------------------------------------------
header "Pre-flight"
# 1. target exists?
if [[ ! -d "$TARGET_PATH" ]]; then
fail "Target directory '$TARGET_PATH' does not exist."
exit 1
fi
# resolve to absolute path
TARGET_PATH="$(cd "$TARGET_PATH" 2>/dev/null && pwd)" || {
fail "Cannot access '$TARGET_PATH'."
exit 1
}
ok "Target: $TARGET_PATH"
# 2. write permission?
if [[ ! -w "$TARGET_PATH" ]]; then
fail "No write permission on '$TARGET_PATH'."
exit 1
fi
ok "Write permission: yes"
# 3. already installed? compare versions
AGENT_DIR="$TARGET_PATH/.agent"
SRC_DIR="$AGENT_DIR/src"
VERSION="$(cat "$METAAGENT_SRC/VERSION" 2>/dev/null || echo '?')"
if [[ -f "$SRC_DIR/VERSION" ]]; then
OLD_VER="$(cat "$SRC_DIR/VERSION" 2>/dev/null || echo '?')"
if [[ "$OLD_VER" != "$VERSION" ]]; then
info "Existing MetaAgent v${OLD_VER} found → upgrading to v${VERSION}"
else
skip "MetaAgent v${VERSION} already installed (use --update to reinstall)"
if [[ "$CHECK" == false ]]; then
warn "No changes applied. Run with --update to overwrite existing files."
fi
fi
else
info "Fresh install: MetaAgent v$VERSION"
fi
# 4. summary
AGENTS_MD="$TARGET_PATH/AGENTS.md"
RULES_DIR="$AGENT_DIR/rules"
DECISIONS_DIR="$AGENT_DIR/decisions"
TASKS_DIR="$AGENT_DIR/tasks"
CONTEXT_DIR="$AGENT_DIR/context"
ARCHIVE_DIR="$AGENT_DIR/archive"
ARCHIVE_TASKS_DIR="$ARCHIVE_DIR/tasks"
ARCHIVE_DECISIONS_DIR="$ARCHIVE_DIR/decisions"
ARCHIVE_CHECKPOINTS_DIR="$ARCHIVE_DIR/checkpoints"
REQUESTS_DIR="$AGENT_DIR/requests"
REQUESTS_ACTIVE_DIR="$REQUESTS_DIR/active"
REQUESTS_ARCHIVE_DIR="$REQUESTS_DIR/archive"
ROADMAP_DIR="$AGENT_DIR/roadmap"
ROADMAP_ARCHIVE_DIR="$ROADMAP_DIR/archive"
TEMP_DIR="$TARGET_PATH/.temp"
if [[ "$CHECK" == true ]]; then
echo ""
info "${ylw}--check mode:${rst} all checks passed, no changes applied."
exit 0
fi
# --- phase 1: directories ------------------------------------------------
header "Directories"
mkdir -p "$SRC_DIR" "$RULES_DIR" "$DECISIONS_DIR" "$TASKS_DIR" "$TASKS_DIR/backlog" \
"$CONTEXT_DIR" "$ARCHIVE_DIR" "$ARCHIVE_TASKS_DIR" "$ARCHIVE_DECISIONS_DIR" \
"$ARCHIVE_CHECKPOINTS_DIR" \
"$REQUESTS_ACTIVE_DIR" "$REQUESTS_ARCHIVE_DIR" \
"$ROADMAP_DIR" "$ROADMAP_ARCHIVE_DIR" \
"$TEMP_DIR"
for d in "$SRC_DIR" "$RULES_DIR" "$DECISIONS_DIR" "$TASKS_DIR" "$TASKS_DIR/backlog" \
"$CONTEXT_DIR" "$ARCHIVE_DIR" "$ARCHIVE_TASKS_DIR" "$ARCHIVE_DECISIONS_DIR" \
"$ARCHIVE_CHECKPOINTS_DIR" \
"$REQUESTS_ACTIVE_DIR" "$REQUESTS_ARCHIVE_DIR" \
"$ROADMAP_DIR" "$ROADMAP_ARCHIVE_DIR" \
"$TEMP_DIR"; do
short="${d#$TARGET_PATH/}"
if [[ -d "$d" ]]; then
ok "$short"
else
fail "$short (creation failed)"
fi
done
# --- phase 2: files ------------------------------------------------------
header "Files"
COPY_COUNT=0
SKIP_COUNT=0
FAIL_COUNT=0
copy_file() {
local src="$1" dst_dir="$2"
local name; name="$(basename "$src")"
local dst="$dst_dir/$name"
if [[ ! -f "$src" ]]; then
skip "$name (source not found)"
SKIP_COUNT=$((SKIP_COUNT + 1))
return
fi
if [[ "$UPDATE" == true ]] || [[ ! -f "$dst" ]]; then
if cp "$src" "$dst"; then
ok "$name"
COPY_COUNT=$((COPY_COUNT + 1))
else
fail "$name"
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
else
skip "$name (exists, use --update to overwrite)"
SKIP_COUNT=$((SKIP_COUNT + 1))
fi
}
copy_dir() {
local src="$1" dst_dir="$2"
local name; name="$(basename "$src")"
local dst="$dst_dir/$name"
if [[ ! -d "$src" ]]; then
skip "$name/ (source not found)"
SKIP_COUNT=$((SKIP_COUNT + 1))
return
fi
mkdir -p "$dst"
if [[ "$UPDATE" == true ]]; then
if cp -rf "$src"/* "$dst/" 2>/dev/null; then
ok "$name/"
COPY_COUNT=$((COPY_COUNT + 1))
else
fail "$name/ (partial copy)"
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
else
cp -rn "$src"/* "$dst/" 2>/dev/null || true
ok "$name/"
COPY_COUNT=$((COPY_COUNT + 1))
fi
}
copy_file "$METAAGENT_SRC/GUIDE.md" "$SRC_DIR"
copy_file "$METAAGENT_SRC/BOUNDARIES.md" "$SRC_DIR"
copy_file "$METAAGENT_SRC/CHANGELOG.md" "$SRC_DIR"
copy_file "$METAAGENT_SRC/WORKFLOW.md" "$SRC_DIR"
copy_file "$METAAGENT_SRC/VERSION" "$SRC_DIR"
copy_dir "$METAAGENT_SRC/PROTOCOLS" "$SRC_DIR"
copy_dir "$METAAGENT_SRC/COMMANDS" "$SRC_DIR"
copy_dir "$METAAGENT_SRC/TEMPLATES" "$SRC_DIR"
copy_file "$METAAGENT_SRC/install.sh" "$SRC_DIR"
copy_file "$METAAGENT_SRC/install.ps1" "$SRC_DIR"
# --- phase 3: AGENTS.md --------------------------------------------------
header "AGENTS.md"
create_agents_md() {
cat > "$1" << AGENTS_EOF
# MetaAgent
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v$VERSION —
набор инструкций для AI-агента.
## Контекст MetaAgent
| Ресурс | Путь |
|--------|------|
| Главная инструкция | \`.agent/src/GUIDE.md\` |
| Протоколы фаз | \`.agent/src/PROTOCOLS/\` |
| Команды (on-demand) | \`.agent/src/COMMANDS/\` |
| Шаблоны артефактов | \`.agent/src/TEMPLATES/\` |
| Границы (что разрешено/запрещено) | \`.agent/src/BOUNDARIES.md\` |
| История версий | \`.agent/src/CHANGELOG.md\` |
| Правила проекта | \`.agent/rules/project-rules.md\` |
| Пример работы | \`.agent/src/WORKFLOW.md\` |
| Версия | \`.agent/src/VERSION\` |
## Состояние сессии (если инициализировано)
| Артефакт | Путь |
|----------|------|
| Чекпоинты сессии | \`.agent/checkpoints.json\` |
| Слепок проекта | \`.agent/context/project-state.md\` |
| Анализ репозитория | \`.agent/context/analysis-report.md\` |
| Дорожная карта | \`.agent/roadmap/sources.md\` |
| Манифест задач | \`.agent/tasks/manifest.json\` |
| Сводка для следующего агента | \`.agent/handoff-summary.md\` |
| Сводка сессии | \`.agent/session-summary.md\` |
## Для агента
Жизненный цикл MetaAgent v$VERSION:
\`\`\`
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
\`\`\`
1. **Прочитай** \`.agent/src/GUIDE.md\` — пойми цикл и доступные команды.
2. **Прочитай** \`.agent/src/BOUNDARIES.md\` — соблюдай границы.
3. **Прочитай** \`.agent/rules/project-rules.md\` — выполни правила пользователя.
4. **Проверь** \`.agent/checkpoints.json\` — если существует, используй как состояние сессии.
5. **Проверь** \`.agent/context/project-state.md\` — получи актуальную картину.
6. **Проверь** \`.agent/tasks/manifest.json\` — если существует, выполняй задачи по порядку.
7. Если \`.agent/\` не инициализирован или устарел — запусти \`install.sh --update\` для
обновления исходников MetaAgent до актуальной версии.
## Команды (on-demand)
В любой момент пользователь может вызвать:
- \`/adr\` — записать архитектурное решение
- \`/red-team\` — попытаться сломать дизайн
- \`/risk-register\` — зафиксировать допущения
- \`/alt-arch\` — описать альтернативу
- \`/invariant-tests\` — тесты-инварианты для ADR
AGENTS_EOF
}
if [[ ! -f "$AGENTS_MD" ]]; then
create_agents_md "$AGENTS_MD"
ok "AGENTS.md created"
elif [[ "$UPDATE" == true ]]; then
create_agents_md "$AGENTS_MD"
ok "AGENTS.md updated"
else
skip "AGENTS.md (exists, use --update to overwrite)"
SKIP_COUNT=$((SKIP_COUNT + 1))
fi
# --- summary -------------------------------------------------------------
header "Summary"
echo " MetaAgent v$VERSION → $SRC_DIR"
echo ""
if (( COPY_COUNT > 0 )); then
ok "${COPY_COUNT} file(s) copied"
fi
if (( SKIP_COUNT > 0 )); then
skip "${SKIP_COUNT} file(s) skipped"
fi
if (( FAIL_COUNT > 0 )); then
fail "${FAIL_COUNT} file(s) failed"
fi
echo ""
if (( FAIL_COUNT == 0 )); then
ok "Installation completed successfully."
else
fail "Installation completed with ${FAIL_COUNT} error(s)."
exit 1
fi
+272
View File
@@ -0,0 +1,272 @@
{
"metaagent_version": "3.0.0",
"session_id": "metaagent-init-2026-10-09",
"target_repo": "S:/Git/nixos",
"goal": "Установить metaagent, перенести накопленные данные (AGENTS.md, docs/arch/*) в структуру .agent/.",
"project_type": "existing",
"date": "2026-10-09T20:30",
"phases": {
"init": "completed",
"analyse": "pending",
"roadmap": "pending",
"design": "skipped",
"decomposition": "pending",
"execution": "pending",
"metastate": "pending",
"handoff": "pending"
},
"tasks": [
{
"id": "T1",
"title": "A1: mobile.nix импортирует несуществующий lib/xlib.nix",
"type": "fix",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"nix flake check проходит на .#nixOnDroidConfigurations.epral",
"configurations/mobile.nix:12 использует import ../lib/xlib"
],
"files": ["configurations/mobile.nix"],
"blocks": ["T15", "T16"],
"notes": "Правка как в configurations/default.nix:5. До правки epral мертв."
},
{
"id": "T2",
"title": "A2: убедиться, что nix flake check вообще запускается",
"type": "verify",
"status": "pending",
"origin": "user:direct",
"depends_on": ["T1"],
"acceptance_criteria": [
"nix flake check зелёный (после T1)",
"checks в deploy покрывают всё дерево outputs"
],
"files": ["deploy/default.nix"],
"blocks": ["T15"]
},
{
"id": "T3",
"title": "A3: явная финальная политика nftables на VDS",
"type": "fix",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"nft list ruleset на otreca показывает явное последнее правило или policy",
"firewall.* либо выключен, либо синхронизирован с nftables (не оба сразу)",
"ssh с внешнего адреса работает"
],
"files": ["configurations/vds.nix"],
"blocks": ["T11", "T12"],
"notes": "Сначала диагностика: nft list ruleset, systemctl status nftables firewall-nftables. Политика — белый список (предпочтительно) или мягкий вариант с явным финальным правилом."
},
{
"id": "T4",
"title": "B1: guard на несмонтированный носитель /mnt/services",
"type": "security",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"В xlib/helpers.nix есть mkStorageGuard",
"postgresql, samba, homebox, gitea, navidrome, syncthing, uptime-kuma, immich, nextcloud, calibre-web, 3x-ui, tape-rotation используют mkStorageGuard",
"Имитация отказа: umount /mnt/services + systemctl start postgresql → FAIL, а не пустая база",
"В AGENTS.md добавлена строка про findmnt перед рестартом (уже в R4)"
],
"files": [
"lib/xlib/helpers.nix",
"modules/server/postgresql.nix",
"modules/server/samba.nix",
"modules/server/homebox.nix",
"modules/server/gitea.nix",
"modules/server/navidrome.nix",
"modules/server/syncthing.nix",
"modules/server/uptime-kuma.nix",
"modules/server/immich.nix",
"modules/server/nextcloud.nix",
"modules/server/calibre-web.nix",
"modules/containers/3x-ui.nix",
"modules/containers/tape-rotation.nix"
],
"blocks": [],
"notes": "ConditionPathIsMountPoint=/mnt/services НЕ использовать (bind-mount внутри одной ФС не меняет st_dev). Надёжны requiresMountsFor или ConditionPathIsMountPoint на xlib.dirs.server-home."
},
{
"id": "T5",
"title": "B2: зафиксировать, что бэкапов в конфигурации нет",
"type": "documentation",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"В project-rules.md (R1.x) явно сказано, что бэкапы вне Nix",
"В project-state.md Open Concerns указано, что бэкапы — внешние"
],
"files": [".agent/rules/project-rules.md", ".agent/context/project-state.md"],
"blocks": [],
"notes": "Ждёт ответа 5.6 — где бэкапы и как проверять. Не код, а запись."
},
{
"id": "T6",
"title": "C1: вернуть расследование 3x-ui, потерянное при откате",
"type": "documentation",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"git show 9974784:modules/containers/3x-ui-migration-notes.md > .agent/decisions/notes/3x-ui-xray-26.9.md (или аналогичный путь)",
"Файл дополнен вердиктом: миграция 26.7 → 26.9 провалена, откат осознанный, причина — X25519MLKEM768"
],
"files": [".agent/decisions/notes/3x-ui-xray-26.9.md"],
"blocks": [],
"notes": "200 строк удалены в 22a19be. git show 9974784:... — восстановить."
},
{
"id": "T7",
"title": "C2: зафиксировать фактические версии панели и ядра 3x-ui",
"type": "investigation",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"podman images ... | grep 3x-ui — записано",
"podman inspect ghcr.io/mhsanaei/3x-ui — RepoDigests записано",
"podman exec 3xui_app /app/bin/xray-linux-amd64 version — записано",
"В modules/containers/3x-ui.nix:54 :latest заменён на конкретный тег/digest"
],
"files": ["modules/containers/3x-ui.nix"],
"blocks": ["T8"]
},
{
"id": "T8",
"title": "C3: убрать сервис автообновления 3x-ui",
"type": "fix",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"podman-update-3xui_app удалён из modules/containers/3x-ui.nix",
"Закомментированный таймер (строки 97-103) удалён",
"Оставлен комментарий-предупреждение"
],
"files": ["modules/containers/3x-ui.nix"],
"blocks": [],
"notes": "Обновление панели через pull = путь, по которому в 2026-10-04 декларация разошлась с рантаймом. Автоматизировать нельзя."
},
{
"id": "T9",
"title": "C4: записать в project-rules, что ядро Xray — состояние панели, а не Nix",
"type": "documentation",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"В project-rules.md (R1.x) явно сказано: версия ядра Xray выбирается в UI панели и лежит в её sqlite-БД, то есть вне Nix",
"Перед деплоем/рестартом 3x-ui проверять версию ядра в панели"
],
"files": [".agent/rules/project-rules.md"],
"blocks": []
},
{
"id": "T10",
"title": "C5: решить судьбу reality443Forwarding",
"type": "decision",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"Решение: (а) оставить + описать в инвариантах, или (б) погасить опцию в vds/default.nix + убрать из options.nix, или (в) довести до рабочего состояния",
"Решение зафиксировано в .agent/decisions/"
],
"files": ["modules/vds/default.nix", "modules/options.nix", "modules/containers/3x-ui.nix"],
"blocks": [],
"notes": "Связано с 6.9."
},
{
"id": "T11",
"title": "D1: пробросы роутера — главный недостающий инвариант",
"type": "documentation",
"status": "pending",
"origin": "user:direct",
"depends_on": ["T3"],
"acceptance_criteria": [
"В project-rules.md (R1.3) формулировка: «Экспозиция наружу определяется пробросами на роутере, не openFirewall. На sapphira networking.firewall.enable = false намеренно. Список пробросов: 22, 80, 443, 8443 (3x-ui/Xray REALITY), 22000 (syncthing). Новый сервис не становится доступен из интернета, пока не добавлен проброс.» (уже сделано)",
"В project-state.md Network секция содержит список пробросов"
],
"files": [".agent/rules/project-rules.md", ".agent/context/project-state.md"],
"blocks": []
},
{
"id": "T12",
"title": "D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira",
"type": "documentation",
"status": "pending",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"В project-rules.md (R1.4) инвариант сформулирован (уже сделано)",
"Список из 4 мест, которые придётся править при смене: modules/server/nginx.nix, modules/server/nextcloud.nix, modules/vds/systemd.nix, modules/vds/nginx.nix"
],
"files": [".agent/rules/project-rules.md"],
"blocks": []
},
{
"id": "T13",
"title": "D3: убрать мёртвое правило firewall на sapphira",
"type": "fix",
"status": "pending",
"origin": "user:direct",
"depends_on": ["T11"],
"acceptance_criteria": [
"modules/server/nginx.nix:225-228 (allowedTCPPorts = [80 443]) удалено или помечено комментарием «депенит от D1»"
],
"files": ["modules/server/nginx.nix"],
"blocks": []
},
{
"id": "T14",
"title": "E1: написать AGENTS.md в корне (с metaagent-шапкой)",
"type": "documentation",
"status": "completed",
"origin": "user:direct",
"depends_on": [],
"acceptance_criteria": [
"AGENTS.md содержит: yaml frontmatter (для Obsidian dataview), metaagent-указатель, краткую выжимку nixos (хосты, инварианты, ловушки, куда лезть, проверки, где не лезть)"
],
"files": ["AGENTS.md"],
"blocks": [],
"notes": "Сделано в этой инициализации (см. объединённый AGENTS.md)."
},
{
"id": "T15",
"title": "E2: выбрать проверки, которые заменят половину инвариантов",
"type": "decision",
"status": "pending",
"origin": "user:direct",
"depends_on": ["T2"],
"acceptance_criteria": [
"Список из 7 кандидатов (см. analysis-report.md §5) отфильтрован владельцем",
"Выбранные проверки превращены в CI или git pre-commit hook"
],
"files": [],
"blocks": []
},
{
"id": "T16",
"title": "E3: судьба 15 закомментированных модулей в modules/server/default.nix:33-47",
"type": "refactor",
"status": "pending",
"origin": "user:direct",
"depends_on": ["T1"],
"acceptance_criteria": [
"Решение: удалить или оставить как референс",
"Если удалять — то 15 модулей (remnawave, coturn, mealie, memos, minecraft, n8n, netdata, nfs, open-webui, rsync, step-ca, stirling-pdf, transmission, trilium, zerotier) перенесены в archive или удалены"
],
"files": ["modules/server/default.nix"],
"blocks": []
}
],
"backlog_count": 23,
"backlog_note": "Открытые вопросы из roadmap/sources.md (F: 2.2, 2.5, 2.6, 3.2, 4.1, 4.2, 4.3, 4.4, 4.5, 5.1, 5.3, 5.4, 5.5, 5.6, 6.6, 6.7, 6.8, 6.9, 7.4, 8.2, 8.4, 8.5, 8.6) ждут ответа владельца и станут задачами после ответа."
}
+86
View File
@@ -0,0 +1,86 @@
# Task Manifest
**Session:** `metaagent-init-2026-10-09`
**Goal:** Установить metaagent, перенести накопленные данные (AGENTS.md, docs/arch/*) в структуру `.agent/`.
**Date:** 2026-10-09T20:30
**Project type:** `existing`
---
## Task Overview
| ID | Title | Type | Depends On | Status | Origin |
|---|---|---|---|---|---|
| T1 | A1: mobile.nix импортирует несуществующий lib/xlib.nix | fix | — | pending | user:direct |
| T2 | A2: убедиться, что nix flake check вообще запускается | verify | T1 | pending | user:direct |
| T3 | A3: явная финальная политика nftables на VDS | fix | — | pending | user:direct |
| T4 | B1: guard на несмонтированный носитель /mnt/services | security | — | pending | user:direct |
| T5 | B2: зафиксировать, что бэкапов в конфигурации нет | docs | — | pending | user:direct |
| T6 | C1: вернуть расследование 3x-ui, потерянное при откате | docs | — | pending | user:direct |
| T7 | C2: зафиксировать фактические версии панели и ядра 3x-ui | investigate | — | pending | user:direct |
| T8 | C3: убрать сервис автообновления 3x-ui | fix | — | pending | user:direct |
| T9 | C4: записать, что ядро Xray — состояние панели, а не Nix | docs | — | pending | user:direct |
| T10 | C5: решить судьбу reality443Forwarding | decision | — | pending | user:direct |
| T11 | D1: пробросы роутера — главный недостающий инвариант | docs | T3 | pending | user:direct |
| T12 | D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira | docs | — | pending | user:direct |
| T13 | D3: убрать мёртвое правило firewall на sapphira | fix | T11 | pending | user:direct |
| T14 | E1: написать AGENTS.md в корне (с metaagent-шапкой) | docs | — | **completed** | user:direct |
| T15 | E2: выбрать проверки, которые заменят половину инвариантов | decision | T2 | pending | user:direct |
| T16 | E3: судьба 15 закомментированных модулей | refactor | T1 | pending | user:direct |
**Total tasks:** 16
**Pending:** 15
**In progress:** 0
**Completed:** 1
**Backlog (ждут ответа):** 23
---
## Задачи по группам
### A. Блокеры (T1–T3)
- **T1 (A1)** — критично: до правки `epral` мёртв. Цена правки: одна строка в `mobile.nix:12`.
- **T2 (A2)** — диагностика: ловит ли `flake check` проблему из T1.
- **T3 (A3)** — опасная зона: править `nftables` без `nft list ruleset` на otreca = риск отрезать SSH.
### B. Защита данных (T4–T5)
- **T4 (B1)** — 12+ сервисов на пустой БД после рестарта без диска. **Самый крупный фикс** (правка `mkStorageGuard` + 12 потребителей).
- **T5 (B2)** — запись в project-rules, не код. Ждёт ответа 5.6.
### C. 3x-ui: заморозить рабочее состояние (T6–T10)
- **T6 (C1)** — восстановить 200 строк из коммита `9974784` + дописать вердикт.
- **T7 (C2)** — сначала диагностика на sapphira и otreca, потом запинить тег.
- **T8 (C3)** — удалить `podman-update-3xui_app` + закомментированный таймер.
- **T9 (C4)** — запись в project-rules (R1.x).
- **T10 (C5)** — связано с вопросом 6.9.
### D. Сетевая граница (T11–T13)
- **T11 (D1)** — запись в project-rules (R1.3) + project-state.md.
- **T12 (D2)** — запись в project-rules (R1.4).
- **T13 (D3)** — мелкая чистка мёртвого правила.
### E. Документация (T14–T16)
- **T14 (E1)** — **выполнено** в этой инициализации.
- **T15 (E2)** — выбор из 7 кандидатов на CI-проверки (см. `analysis-report.md §5`).
- **T16 (E3)** — рефакторинг 15 модулей.
### F. Backlog (ждут ответа)
23 вопроса из `roadmap/sources.md` (F: 2.2, 2.5, 2.6, 3.2, 4.1, 4.2, 4.3, 4.4, 4.5, 5.1, 5.3, 5.4, 5.5, 5.6, 6.6, 6.7, 6.8, 6.9, 7.4, 8.2, 8.4, 8.5, 8.6) — становятся задачами после ответа владельца.
---
## Зависимости
```
T1 → T2 → T15
T1 → T16
T3 → T11 → T13
```
Остальные задачи можно делать параллельно.
+3
View File
@@ -1,2 +1,5 @@
.vscode .vscode
.omo .omo
__pycache__
scripts
.temp
+145
View File
@@ -0,0 +1,145 @@
---
aliases: []
cssclasses:
date-created: 2026-10-09T19:01
date-modified: 2026-10-09T20:30
tags: [metaagent, nixos, agents]
---
# AGENTS.md
Этот проект использует [MetaAgent](.agent/src/GUIDE.md) v3.0.0 — набор
инструкций для AI-агента.
> NixOS-конфиг домашнего флота. 6 NixOS-хостов + Android (`nix-on-droid`).
> Этот файл — то, что агент должен прочитать **до** первого изменения. Если
> задача выглядит так, что требует сломать что-то из «Подтверждённых
> инвариантов» или «Ловушек» ниже — остановиться и спросить.
## Контекст MetaAgent
| Ресурс | Путь |
|--------|------|
| Главная инструкция | `.agent/src/GUIDE.md` |
| Протоколы фаз | `.agent/src/PROTOCOLS/` |
| Команды (on-demand) | `.agent/src/COMMANDS/` |
| Шаблоны артефактов | `.agent/src/TEMPLATES/` |
| Границы (что разрешено/запрещено) | `.agent/src/BOUNDARIES.md` |
| История версий | `.agent/src/CHANGELOG.md` |
| Правила проекта (полные) | `.agent/rules/project-rules.md` |
| Слепок проекта | `.agent/context/project-state.md` |
| Анализ репозитория | `.agent/context/analysis-report.md` |
| Дорожная карта | `.agent/roadmap/sources.md` |
| Манифест задач | `.agent/tasks/manifest.json` |
| ADR (архитектурные решения) | `.agent/decisions/` |
| Пример работы | `.agent/src/WORKFLOW.md` |
| Версия | `.agent/src/VERSION` |
## Архитектура (30 секунд)
```
flake.nix
├── configurations/ ← реестр хостов (1 запись = 1 машина)
├── modules/ ← essentials + per-type (desktop/server/vds/wsl/containers/termux)
├── home/ ← home-manager (per device-type)
├── lib/xlib/ ← чистые данные: devices, dirs, helpers
├── deploy/, secrets/ (sops), overlays/, pkgs/
└── .agent/ ← MetaAgent state (rules, decisions, tasks, context, requests, roadmap)
```
Подробная карта: `.agent/context/project-state.md` и `.agent/context/analysis-report.md`.
## Хосты
| Attr / имя | device.type | Роль | Деплой | stateVersion | Примечание |
|---|---|---|---|---|---|
| `default` (nixos) | minimal | Шаблон / минималка | — | — | hostname `"nixos"` |
| `atoridu` | primary | Основной десктоп | — (manual) | 26.05 | xanmod, mini-PC |
| `rydiwo` | secondary | Chuwi MiniBook | deploy-rs | 26.05 | xanmod, NTFS `lamet-drive` |
| `otrecа` | vds | VPS | deploy-rs | 25.05 | Tailscale-only SSH, nftables (см. T3) |
| `sapphira` | server | Домашний сервер | deploy-rs | 25.05 | `firewall.enable = false` намеренно |
| `wsl` | wsl | WSL NixOS | — (manual) | 24.11 | на vetymae (Windows 192.168.1.100) |
| `epral` | termux | Android | — | 24.05 | nix-on-droid, через `mobile.nix` |
## Подтверждённые инварианты
> Полные формулировки (с «Где» и «Почему») — в `.agent/rules/project-rules.md` (R1).
1. **Все `outputs` флейка должны вычисляться.** `configurations/mobile.nix:12` импортировал несуществующий `lib/xlib.nix` — был сломан, `epral` не собирался. → задача T1.
2. **External-диск обязан быть смонтирован** до старта `postgresql`, `n8n`, `samba`, `homebox`, `minecraft`, `3x-ui`, `tape-rotation`. → задача T4.
3. **Сетевая граница sapphira — роутер.** 5 портов: **443, 80, 22000 (syncthing), 8443 (xray), 22 (ssh)**. `firewall.enable = false` намеренно. → задача T11.
4. **`100.64.0.0` = Tailscale-адрес sapphira**, назначен вручную. В 4 файлах. → задача T12.
5. **3x-ui заморожен.** Панель на `:latest`, ядро Xray на 26.7.x. Миграция на 26.9.x провалена. → задачи T6–T10.
6. **nftables на VDS требует явной финальной политики.** Текущий ruleset — без финального правила → неявный accept. → задача T3.
7. **sops-пути — через `config.sops.secrets.<name>.path`.** Любой `path =` override на sops-блоке делает хардкод-потребителя молча сломанным. → ADR-0001.
## Ловушки (выглядит сломанным, намеренно)
| Где | Что выглядит ошибкой | На самом деле |
|---|---|---|
| `server.nix:130` | `firewall.enable = false` при 20 сервисах на `0.0.0.0` | Роутер фильтрует, см. инв. 3 |
| `mobile.nix:95`, `wsl.nix:59` | `stateVersion` 24.05 / 24.11 vs 26.05 | Каждый хост зафиксирован на своей версии |
| `users.nix:66` | `uid = if hostname == "sapphira" then 1001 else …` | Костыль под 1000 = удалённый `yuyus`; удалять только после миграции ФС |
| `3x-ui.nix:54` | `image = …:latest` | Панель намеренно latest; ядро Xray — на 26.7.x |
| `3x-ui.nix:33-35` | `reality443Forwarding = true` на VDS | Следствие отката `c8d4a12`; см. задачу T10 |
| `server/default.nix:33-47` | 15 закомментированных модулей | Отключены осознанно, см. задачу T16 |
| `opencode.nix:339` | `systemd.user.services.opencode-web.Service` | `serviceConfig` рендерится в секцию `[serviceConfig]`, systemd молча игнорирует; см. R2 |
| `vds.nix:73-91` | nftables без финального правила | Известный пробел, см. задачу T3 |
| `100.64.0.0` | Первый адрес CGNAT `/10` | Tailscale-адрес sapphira, см. инв. 4 |
| `server.nix:61-63` | `z /mnt/services 0777` | World-writable точка монтирования; см. задачу T4 |
## Куда лезть по задаче
| Задача | Файл |
|---|---|
| Добавить хост | `configurations/default.nix` + `configurations/<host>.nix` + `configurations/{hardware,disko}/<host>.nix` |
| Добавить системный сервис | `modules/server/<name>.nix`, добавить в `modules/server/default.nix:imports` |
| Добавить home-пакет | `home/<device_type>.nix` |
| Добавить кросс-модульную опцию | `modules/options.nix` |
| Изменить mount/имя пользователя | `lib/xlib/dirs.nix`, `lib/xlib/device.nix` |
| Изменить домен / сертификат | `modules/server/coredns.nix` + `modules/server/nginx.nix` (или `vds/`) |
| Sops-секрет | `secrets/<name>.<yaml\|json\|env\|ini>`; `users.nix:99` подключает `secrets/default.yaml`; dotenv/json — через `mkUserSecret` |
## Проверки
```bash
# все outputs вычисляются
nix flake check
# правки применились на целевой хост
nix build .#nixosConfigurations.<host>.config.system.build.toplevel
# nixOnDroid
nix build .#nixOnDroidConfigurations.epral.config.system.build.toplevel
# внешний диск смонтирован (до рестарта сервисов на нём)
findmnt /home/oqyude/External
findmnt /mnt/services
# sops
sops --version
```
## Где НЕ лезть без ответа владельца
- `secrets/` (sops-encrypted, расшифровываются `/etc/ssh/id_ed25519` → циклический bootstrap).
- `let deploy` без проверки deploy-rs нод: `rydiwo` (ноутбук, может быть выключен).
- Любая правка, противоречащая «Подтверждённым инвариантам» выше.
- Ядро Xray 26.7.x → 26.9.x — миграция провалена, не повторять без отдельной задачи.
## Команды MetaAgent (on-demand)
- `/adr` — записать архитектурное решение
- `/red-team` — попытаться сломать дизайн
- `/risk-register` — зафиксировать допущения
- `/alt-arch` — описать альтернативу
- `/invariant-tests` — тесты-инварианты для ADR
## Жизненный цикл MetaAgent v3.0.0
```
INIT → ANALYSE → ROADMAP → [DESIGN] → DECOMPOSITION → EXECUTION → METASTATE → HANDOFF
```
Текущее состояние: см. `.agent/checkpoints.json` (`phases.init = completed`,
`phases.analyse = completed`, `phases.roadmap = completed`,
`phases.decomposition = completed`, `phases.execution = in_progress`).
+4 -7
View File
@@ -1,7 +1,7 @@
{ # Host: "default" (device: minimal)
deviceType = "minimal"; #
modules = [ # The host record lives in configurations/default.nix; this file is only the
( # module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
{ {
inputs, inputs,
... ...
@@ -13,6 +13,3 @@
system.stateVersion = "26.05"; system.stateVersion = "26.05";
} }
)
];
}
+63 -8
View File
@@ -1,18 +1,73 @@
{ inputs, ... }@flakeContext: { inputs, ... }@flakeContext:
let let
lib = inputs.nixpkgs.lib;
mkSystem = import ../lib/mkSystem.nix flakeContext; mkSystem = import ../lib/mkSystem.nix flakeContext;
xlibLib = import ../lib/xlib { inherit lib; };
# One record per host. The attribute name IS the hostname, so it is written
# exactly once; `hostname` is only needed where the attribute name is not
# the real hostname (the `default` entry).
#
# device device type, must be a key of `devices` in lib/xlib/device.nix
# modules module body for this host
hosts = {
default = {
hostname = "nixos";
device = "minimal";
modules = [ ./any.nix ];
};
atoridu = {
device = "primary";
modules = [ ./mini-pc.nix ];
};
rydiwo = {
device = "secondary";
modules = [ ./mini-laptop.nix ];
};
otreca = {
device = "vds";
modules = [ ./vds.nix ];
};
sapphira = {
device = "server";
modules = [ ./server.nix ];
};
wsl = {
device = "wsl";
modules = [ ./wsl.nix ];
};
};
mkHost =
name:
{
device,
modules,
hostname ? name,
...
}:
let
xlib = xlibLib.mkXlib {
inherit hostname;
type = device;
};
in in
{ {
nixosConfigurations = { inherit xlib;
default = mkSystem (import ./any.nix); # default system = mkSystem { inherit xlib modules; };
atoridu = mkSystem (import ./mini-pc.nix); # atoridu
rydiwo = mkSystem (import ./mini-laptop.nix); # rydiwo
otreca = mkSystem (import ./vds.nix); # vds
sapphira = mkSystem (import ./server.nix); # sapphira
wsl = mkSystem (import ./wsl.nix); # wsl
}; };
in
{
nixosConfigurations = lib.mapAttrs' (
name: spec: lib.nameValuePair name (mkHost name spec).system
) hosts;
# Per-host xlib values, for code that lives outside the module system
# (deploy, overlays, pkgs).
xlib = lib.mapAttrs' (name: spec: lib.nameValuePair name (mkHost name spec).xlib) hosts;
nixOnDroidConfigurations = { nixOnDroidConfigurations = {
epral = import ./mobile.nix flakeContext; # epral (Android via nix-on-droid) epral = import ./mobile.nix flakeContext; # epral (Android device via nix-on-droid)
# Alias so a plain `nix-on-droid switch` from a local clone # Alias so a plain `nix-on-droid switch` from a local clone
# (~/.config/nix-on-droid) picks up the device config without `#epral`. # (~/.config/nix-on-droid) picks up the device config without `#epral`.
default = import ./mobile.nix flakeContext; default = import ./mobile.nix flakeContext;
+9 -20
View File
@@ -1,8 +1,7 @@
{ # Host: "rydiwo" (device: secondary)
deviceType = "secondary"; #
hostname = "rydiwo"; # The host record lives in configurations/default.nix; this file is only the
modules = [ # module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
(
{ {
lib, lib,
pkgs, pkgs,
@@ -25,24 +24,14 @@
}; };
}; };
fileSystems."${xlib.dirs.lamet-drive}" = { fileSystems = xlib.helpers.mkNtfsMount {
device = "/dev/disk/by-uuid/DC76BD3576BD116E"; path = xlib.dirs.lamet-drive;
fsType = "ntfs3"; uuid = "DC76BD3576BD116E";
options = [ mask = "0000";
"defaults"
"uid=1000"
"gid=1000"
"fmask=0000"
"dmask=0000"
"nofail"
];
}; };
xlib.ssh.enable = true; host.ssh.enable = true;
hardware.intel-gpu-tools.enable = true; hardware.intel-gpu-tools.enable = true;
system.stateVersion = "26.05"; system.stateVersion = "26.05";
} }
)
];
}
+28 -49
View File
@@ -1,8 +1,7 @@
{ # Host: "atoridu" (device: primary)
deviceType = "primary"; #
hostname = "atoridu"; # The host record lives in configurations/default.nix; this file is only the
modules = [ # module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
(
{ {
lib, lib,
pkgs, pkgs,
@@ -18,47 +17,30 @@
self.nixosModules.default self.nixosModules.default
]; ];
fileSystems = { # mkNtfsMount returns a `{ "<path>" = { ... }; }` attrset (the shape
"${xlib.dirs.therima-drive}" = { # fileSystems itself wants), so several mounts are combined with
enable = false; # mergeAttrsList — not listToAttrs, which would demand `name`/`value`.
device = "/dev/disk/by-uuid/C0A2DDEFA2DDEA44"; #
fsType = "ntfs3"; # These three ntfs3 drives are intentionally left unmounted. The entries
options = [ # are kept commented out rather than deleted, so restoring a drive is a
"defaults" # matter of uncommenting its block. `enable = false` would declare a drive
"uid=1000" # without mounting it; dropping the field mounts it.
"gid=1000" fileSystems = lib.mergeAttrsList (
"fmask=0007" map (xlib.helpers.mkNtfsMount) [
"dmask=0007" # {
"nofail" # path = xlib.dirs.therima-drive;
]; # uuid = "C0A2DDEFA2DDEA44";
}; # }
"${xlib.dirs.vetymae-drive}" = { # {
enable = false; # path = xlib.dirs.vetymae-drive;
device = "/dev/disk/by-uuid/6408433908430A0E"; # uuid = "6408433908430A0E";
fsType = "ntfs3"; # }
options = [ # {
"defaults" # path = xlib.dirs.soptur-drive;
"uid=1000" # uuid = "C00C56E40C56D54E";
"gid=1000" # }
"fmask=0007" ]
"dmask=0007" );
"nofail"
];
};
"${xlib.dirs.soptur-drive}" = {
enable = false;
device = "/dev/disk/by-uuid/C00C56E40C56D54E";
fsType = "ntfs3";
options = [
"defaults"
"uid=1000"
"gid=1000"
"fmask=0007"
"dmask=0007"
"nofail"
];
};
};
boot = { boot = {
kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable; kernelPackages = lib.mkDefault pkgs.linuxPackages_xanmod_stable;
@@ -100,6 +82,3 @@
system.stateVersion = "26.05"; system.stateVersion = "26.05";
} }
)
];
}
+14 -9
View File
@@ -9,6 +9,7 @@ let
# (essentials, users.nix, home-manager, sops-nix, disko, grub2-themes) # (essentials, users.nix, home-manager, sops-nix, disko, grub2-themes)
# and nixpkgs.overlays are skipped so it evaluates under nix-on-droid's # and nixpkgs.overlays are skipped so it evaluates under nix-on-droid's
# module system (class = "nixOnDroid"). # module system (class = "nixOnDroid").
xlib = import ../lib/xlib.nix { lib = inputs.nixpkgs.lib; };
nixOnDroidModule = nixOnDroidModule =
{ {
lib, lib,
@@ -21,20 +22,15 @@ let
inputs.self.nixosModules.strict inputs.self.nixosModules.strict
]; ];
xlib.device = {
type = "termux";
hostname = "epral";
};
# Login shell. nix-on-droid writes /etc/passwd from user.shell on every # Login shell. nix-on-droid writes /etc/passwd from user.shell on every
# activation, so `chsh` is useless here — set it in nix instead. # activation, so `chsh` is useless here — set it in nix instead.
# (default is bashInteractive) # (default is bashInteractive)
user.shell = "${pkgs.zsh}/bin/zsh"; user.shell = "${pkgs.zsh}/bin/zsh";
# SSH user (matches `User oqyude` in the client's ~/.ssh/config). # SSH user (matches the `User` entries in the client's ~/.ssh/config).
# Default is "nix-on-droid"; home stays at the read-only # Default is "nix-on-droid"; home stays at the read-only
# /data/data/com.termux.nix/files/home either way. # /data/data/com.termux.nix/files/home either way.
user.userName = "oqyude"; user.userName = xlib.device.username;
# Minimal termux settings (nix-on-droid options only: # Minimal termux settings (nix-on-droid options only:
# environment.*, nix.*, time.*, user.*, system.*, android-integration.*) # environment.*, nix.*, time.*, user.*, system.*, android-integration.*)
@@ -85,7 +81,9 @@ let
extraSpecialArgs = { extraSpecialArgs = {
inherit xlib; inherit xlib;
}; };
config = { ... }: { config =
{ ... }:
{
imports = [ imports = [
../home/termux.nix ../home/termux.nix
]; ];
@@ -118,6 +116,13 @@ inputs.nix-on-droid.lib.nixOnDroidConfiguration {
nixOnDroidModule nixOnDroidModule
]; ];
extraSpecialArgs = { extraSpecialArgs = {
deviceType = "termux"; # `xlib` is the same value shape NixOS hosts get (lib/mkSystem.nix);
# the hostname lives here because nixOnDroidConfigurations is keyed by
# both "epral" and the "default" alias, so it cannot come from the
# attribute name.
xlib = xlib.mkXlib {
hostname = "epral";
type = "termux";
};
}; };
} }
+79 -39
View File
@@ -1,8 +1,7 @@
{ # Host: "sapphira" (device: server)
deviceType = "server"; #
hostname = "sapphira"; # The host record lives in configurations/default.nix; this file is only the
modules = [ # module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
(
{ {
lib, lib,
pkgs, pkgs,
@@ -38,48 +37,92 @@
intel-gpu-tools.enable = true; intel-gpu-tools.enable = true;
}; };
fileSystems = { fileSystems =
(xlib.helpers.mkExfatMount {
path = xlib.dirs.archive-drive;
label = "archive";
})
// (xlib.helpers.mkExfatMount {
path = xlib.dirs.mobile-drive;
uuid = "7EB1-DC99";
})
// (xlib.helpers.mkBindMount {
what = xlib.dirs.services-folder;
where = xlib.dirs.services-mnt-folder;
})
// {
# External drive # External drive
"${xlib.dirs.server-home}" = { "${xlib.dirs.server-home}" = {
device = "/dev/disk/by-uuid/37e53ebc-5343-a94d-9fe2-0ca39e13a8de"; device = "/dev/disk/by-uuid/37e53ebc-5343-a94d-9fe2-0ca39e13a8de";
fsType = "ext4"; fsType = "ext4";
}; };
# Archive drive
"${xlib.dirs.archive-drive}" = {
device = "/dev/disk/by-label/archive";
fsType = "exfat";
options = [
"nofail"
"uid=1000"
"gid=1000"
];
};
# Mobile SD-Card
"${xlib.dirs.mobile-drive}" = {
device = "/dev/disk/by-uuid/7EB1-DC99";
fsType = "exfat";
options = [
"nofail"
"uid=1000"
"gid=1000"
];
};
# Services in /mnt folder
"${xlib.dirs.services-mnt-folder}" = {
device = "${xlib.dirs.services-folder}";
fsType = "none";
options = [
"bind"
"nofail"
];
};
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"z ${xlib.dirs.services-mnt-folder} 0777 root root -" "z ${xlib.dirs.services-mnt-folder} 0777 root root -"
]; ];
xlib.ssh.enable = true; host.ssh.enable = true;
# Offload Nix builds to the WSL2 NixOS instance running on vetymae
# (Windows 11 host). Sapphira only has 2 logical cores; the WSL exposes
# 24 cores + 14 GiB. The matchBlock with ProxyCommand is generated by
# modules/server/builder.nix, the other side of the same option lives in
# modules/wsl/builder.nix.
#
# `proxyCommand` is what marks this builder as needing the SSH matchBlock
# (see modules/server/builder.nix). A builder reachable directly would
# omit it.
#
# ---- DISABLED 2026-10-04 ----
# Remote building temporarily turned off coordinator-side. `host.builder.clients`
# falls back to its default `[]` (declared in modules/options.nix), so
# modules/server/builder.nix's `lib.mkIf (clients != [])` never fires and no
# buildMachines / SSH blocks / distributedBuilds override get generated.
# All builds run locally on sapphira's 2 cores. Re-enable by removing the
# Nix comments on the block below (and on `host.builder.enable = true;`
# in configurations/wsl.nix).
#
# host.builder.clients = [
# {
# hostName = "vetymae-nix";
# sshUser = "oqyude";
# sshKey = "/root/.ssh/id_ed25519";
# # NixOS calls this `systems` (plural), not `systemTypes`. The
# # default is empty — every derivation is rejected. The WSL NixOS
# # runs on x86_64-linux, matching sapphira.
# systems = [ "x86_64-linux" ];
# # vetymae-nix drops kvm + nixos-test from its advertised
# # system-features (see modules/wsl/builder.nix). Listing them here
# # would not break anything (Nix intersects), but listing the
# # features the WSL actually has is the documented contract.
# supportedFeatures = [
# "benchmark"
# "big-parallel"
# ];
# mandatoryFeatures = [ ];
# maxJobs = 24;
# speedFactor = 0.5;
# # Keep the SSH session alive across many small builds in one daemon
# # session — compile-heavy workloads spam the daemon with hundreds of
# # derivations and ControlMaster collapses those into one Windows hop.
# # NB: `nix.buildMachines` has no `sshOptions` attribute, so the
# # ControlMaster directive lives in the SSH matchBlock instead (see
# # modules/server/builder.nix).
# #
# # The OpenSSH alias for this host (matches the user's
# # ~/.ssh/config so known_hosts entries do not collide with the
# # Windows OpenSSH entry on 127.0.0.1/vetymae) is consumed only by
# # the SSH matchBlock below — not by `nix.buildMachines`, which has
# # no such attribute.
# hostKeyAlias = "wsl-nixos-on-vetymae";
# # Use the Windows host's IP directly so the nix-daemon (running as
# # root, without the user's ~/.ssh/config) does not need a separate
# # `vetymae` host alias. With StrictHostKeyChecking=accept-new the
# # first connection adds the Windows host key to /root/.ssh/known_hosts.
# proxyCommand = "ssh oqyude@192.168.1.100 'wsl -d NixOS -- nc 127.0.0.1 22'";
# }
# ];
networking = { networking = {
networkmanager.enable = true; networkmanager.enable = true;
@@ -94,6 +137,3 @@
stateVersion = "25.05"; stateVersion = "25.05";
}; };
} }
)
];
}
+11 -11
View File
@@ -1,10 +1,8 @@
# Host: "otreca" (device: vds)
#
# The host record lives in configurations/default.nix; this file is only the
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
{ {
deviceType = "vds";
hostname = "otreca";
modules = [
(
{
config,
lib, lib,
modulesPath, modulesPath,
pkgs, pkgs,
@@ -43,13 +41,18 @@
}; };
}; };
xlib.ssh.enable = true; host.ssh.enable = true;
services.openssh.openFirewall = true; # SSH is reachable only over Tailscale (not on the public internet).
# This otreca VDS is reached by deploy-rs and by oqyude over the
# tailnet, so exposing 22 to ens3 is pure attack surface.
services.openssh.openFirewall = false;
services.tailscale = { services.tailscale = {
enable = true; enable = true;
openFirewall = true; openFirewall = true;
}; };
# Open port 22 only on the tailscale interface.
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ 22 ];
networking = { networking = {
nameservers = [ nameservers = [
"1.1.1.1" "1.1.1.1"
@@ -117,6 +120,3 @@
stateVersion = "25.05"; stateVersion = "25.05";
}; };
} }
)
];
}
+27 -11
View File
@@ -1,13 +1,11 @@
# Host: "wsl" (device: wsl)
#
# The host record lives in configurations/default.nix; this file is only the
# module body. `xlib` (identity, dirs, helpers) arrives as a module argument.
{ {
deviceType = "wsl";
hostname = "wsl";
modules = [
(
{
config,
lib, lib,
pkgs,
modulesPath, modulesPath,
pkgs,
xlib, xlib,
inputs, inputs,
... ...
@@ -34,11 +32,29 @@
enable = true; enable = true;
startMenuLaunchers = true; startMenuLaunchers = true;
useWindowsDriver = true; useWindowsDriver = true;
defaultUser = config.xlib.device.username; defaultUser = xlib.device.username;
}; };
# Enable SSH server on WSL NixOS so sapphira can drive it directly via a
# ProxyCommand chain through the Windows OpenSSH layer. The shared
# essentials/ssh.nix module wires host keys, sops-managed user keys, and
# passwordless key auth — nothing to repeat here.
host.ssh.enable = true;
# Advertise this WSL instance as a remote Nix builder for sapphira (2
# cores, the bottleneck host). All builder wiring — fixing the
# `system-features` to drop the unsupported `kvm`, and adding the SSH
# user `oqyude` to trusted-users — lives in modules/wsl/builder.nix.
#
# ---- DISABLED 2026-10-04 ----
# Remote building temporarily turned off builder-side. The default of
# `host.builder.enable` is `false` (modules/options.nix), so
# modules/wsl/builder.nix's `lib.mkIf enable` block is skipped: WSL
# keeps its default system-features and trusted-users, and no SSH-side
# state changes. Re-enable by uncommenting the assignment below and
# removing the DISABLED banner in configurations/server.nix.
#
# host.builder.enable = true;
system.stateVersion = "24.11"; system.stateVersion = "24.11";
} }
)
];
}
+3 -1
View File
@@ -6,7 +6,9 @@ let
path = inputs.deploy-rs.lib.x86_64-linux.activate.nixos inputs.self.nixosConfigurations.${hostname}; path = inputs.deploy-rs.lib.x86_64-linux.activate.nixos inputs.self.nixosConfigurations.${hostname};
}; };
}; };
user = "${inputs.self.nixosConfigurations.default.config.xlib.device.username}"; # Login user for every deploy target. Read from the hoisted xlib instead of
# digging through a built NixOS configuration.
user = "${inputs.self.xlib.default.device.username}";
server = "sapphira"; server = "sapphira";
vds = "otreca"; vds = "otreca";
mini-laptop = "rydiwo"; mini-laptop = "rydiwo";
Generated
+177 -81
View File
@@ -13,11 +13,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785892481, "lastModified": 1789404474,
"narHash": "sha256-1JTy9/LyITSBP1a4WZ9tmOv2yDh9OTbA3YUJbQHiMYc=", "narHash": "sha256-UXFQ7tFiwn8sPz0EV4CBB2PCf/ZiGIHWn/6MXk81Lxs=",
"owner": "serokell", "owner": "serokell",
"repo": "deploy-rs", "repo": "deploy-rs",
"rev": "f6f2359a6cb7e3c51ea673bcb39933ac2622350d", "rev": "e760371d631165e7d8de5b0dcf148e21ec4c16f0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -33,11 +33,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1781152676, "lastModified": 1789770686,
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=", "narHash": "sha256-uZkBR7yHdIKUFB5SZdfgh1qkGfI3XmYmI/lTiquxbck=",
"owner": "nix-community", "owner": "nix-community",
"repo": "disko", "repo": "disko",
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1", "rev": "725ea35e410ad83be4931d1bff7e090eacaf3563",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -61,6 +61,24 @@
"type": "github" "type": "github"
} }
}, },
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1788450739,
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"grub2-themes": { "grub2-themes": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -68,11 +86,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1757136219, "lastModified": 1788271742,
"narHash": "sha256-tKU+vq34KHu/A2wD7WdgP5A4/RCmSD8hB0TyQAUlixA=", "narHash": "sha256-H4IIqM+fmq9t3ZPHGs9kiuoQzau9AhCGQBSfClqQ/44=",
"owner": "vinceliuice", "owner": "vinceliuice",
"repo": "grub2-themes", "repo": "grub2-themes",
"rev": "80dd04ddf3ba7b284a7b1a5df2b1e95ee2aad606", "rev": "4c5a77125b93f833edc9bf7b14a899faa8ac79c6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -88,11 +106,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1786031233, "lastModified": 1790128814,
"narHash": "sha256-TIDlLTLI1/pB7IqgjzcKQjpODQsZE2oII4XGG9B6KjI=", "narHash": "sha256-6Gm9q+wW3E4Ey4F6wEbJAwaMsEK6hvCYfTW7yY64ZfA=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "7834e82588860aaf780cec1366524456a70898d7", "rev": "0b2f1129177f70c5f0f5d88bb53c49ca47d0bfc0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -101,6 +119,45 @@
"type": "github" "type": "github"
} }
}, },
"justray": {
"inputs": {
"flake-parts": [
"flake-parts"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1790165840,
"narHash": "sha256-nQ3uCXiUGTLD/UtuChc2XlStYg9a33PYrkDitmmqxW8=",
"owner": "luynrs",
"repo": "justray",
"rev": "4073f3fb223613e4d780dafad6f93b5c266061a2",
"type": "github"
},
"original": {
"owner": "luynrs",
"repo": "justray",
"type": "github"
}
},
"nfqws2-keenetic": {
"flake": false,
"locked": {
"lastModified": 1789144514,
"narHash": "sha256-G+LvvXDqzYm8SOXNc3N+HIzvD9DR3J+WT+HHDdmjB0Y=",
"owner": "nfqws",
"repo": "nfqws2-keenetic",
"rev": "fa22c177b340e73d8b8a94b295af20e0228c4c22",
"type": "github"
},
"original": {
"owner": "nfqws",
"repo": "nfqws2-keenetic",
"type": "github"
}
},
"nix-formatter-pack": { "nix-formatter-pack": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -127,6 +184,32 @@
"type": "github" "type": "github"
} }
}, },
"nix-minecraft": {
"inputs": {
"flake-compat": [
"flake-compat"
],
"nixpkgs": [
"nixpkgs"
],
"systems": [
"nix-systems"
]
},
"locked": {
"lastModified": 1790137578,
"narHash": "sha256-luzO2Bo/RxUHqTPxBttSB1QVzM9Y5s+Iwpip6SjWdWo=",
"owner": "Infinidoge",
"repo": "nix-minecraft",
"rev": "2e6a1d1ceb4da6b6ffb3980bc7993b3d057cd4db",
"type": "github"
},
"original": {
"owner": "Infinidoge",
"repo": "nix-minecraft",
"type": "github"
}
},
"nix-on-droid": { "nix-on-droid": {
"inputs": { "inputs": {
"home-manager": [ "home-manager": [
@@ -155,16 +238,33 @@
"type": "github" "type": "github"
} }
}, },
"nix-systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"nixos-hardware": { "nixos-hardware": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "nixpkgs": [
"nixpkgs"
]
}, },
"locked": { "locked": {
"lastModified": 1785232496, "lastModified": 1789978172,
"narHash": "sha256-65EQYIRRpTdpH8lUiB6Mvo5uBkG60aBIzAJuALfx+O0=", "narHash": "sha256-FIRXajv1pPZ+l6On6ekkmcQ6le0Zi0ncRUoR3nB0vKA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixos-hardware", "repo": "nixos-hardware",
"rev": "2e790b0a6be8ec2b76174ac0931b8ff11919ec98", "rev": "9ebcb7766700d7e006d9505247bd7ce0426f4232",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -184,11 +284,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1784642409, "lastModified": 1789164534,
"narHash": "sha256-hcbDqFuySAJawljt5r0sKBCJKYnbtGD0T/ZIozH1Dq0=", "narHash": "sha256-DoYGPM6QpnYBLWj9gGw6ZwAzIX+HrAVov1BoT+8Jixo=",
"owner": "nix-community", "owner": "nix-community",
"repo": "NixOS-WSL", "repo": "NixOS-WSL",
"rev": "eaeb18da90024448a60eb1ec7132eafa4003404e", "rev": "72c92b11bb8289e6651c7fef29cc0a885fd6a255",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -200,15 +300,18 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1767892417, "lastModified": 1790046670,
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=", "narHash": "sha256-MYiI+CzL0tuWgRPjGsKCDHqYs2T3OzMlMQWOYWG0qso=",
"rev": "3497aa5c9457a9d88d71fa93a4a8368816fbeeba", "owner": "NixOS",
"type": "tarball", "repo": "nixpkgs",
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre924538.3497aa5c9457/nixexprs.tar.xz" "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc",
"type": "github"
}, },
"original": { "original": {
"type": "tarball", "owner": "NixOS",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" "ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
} }
}, },
"nixpkgs-docs": { "nixpkgs-docs": {
@@ -243,19 +346,18 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1785967620, "lastModified": 1788057806,
"narHash": "sha256-IItrdb7Puk05RqOBWZYFC5X6Wl1sJmCfh5MWVHw5iMM=", "narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
"owner": "NixOS", "owner": "nix-community",
"repo": "nixpkgs", "repo": "nixpkgs.lib",
"rev": "b7c2ada94fe99c15b0dbcf4d11fd7850b957a436", "rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "nix-community",
"ref": "nixos-unstable", "repo": "nixpkgs.lib",
"repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
@@ -322,17 +424,19 @@
}, },
"proxy-suite": { "proxy-suite": {
"inputs": { "inputs": {
"nfqws2-keenetic": "nfqws2-keenetic",
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
], ],
"z2k": "z2k",
"zapret": "zapret" "zapret": "zapret"
}, },
"locked": { "locked": {
"lastModified": 1785752612, "lastModified": 1790130850,
"narHash": "sha256-xplyGWMis+MHSOJcwlnOTlebJGNag0qFsZ719JQWsz8=", "narHash": "sha256-k7c+KGZmNLP0ZB9jnKKJUXUTvEJC8A6kHQmZlcN8kNQ=",
"owner": "FUFSoB", "owner": "FUFSoB",
"repo": "proxy-suite-flake", "repo": "proxy-suite-flake",
"rev": "47a07d971cf53e62b912d2809a9a711275ceb00d", "rev": "8f65fa9c255e9350fb09f3d3cc9054034918bf49",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -346,17 +450,20 @@
"deploy-rs": "deploy-rs", "deploy-rs": "deploy-rs",
"disko": "disko", "disko": "disko",
"flake-compat": "flake-compat", "flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"grub2-themes": "grub2-themes", "grub2-themes": "grub2-themes",
"home-manager": "home-manager", "home-manager": "home-manager",
"justray": "justray",
"nix-minecraft": "nix-minecraft",
"nix-on-droid": "nix-on-droid", "nix-on-droid": "nix-on-droid",
"nix-systems": "nix-systems",
"nixos-hardware": "nixos-hardware", "nixos-hardware": "nixos-hardware",
"nixos-wsl": "nixos-wsl", "nixos-wsl": "nixos-wsl",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs",
"plasma-manager": "plasma-manager", "plasma-manager": "plasma-manager",
"proxy-suite": "proxy-suite", "proxy-suite": "proxy-suite",
"sops-nix": "sops-nix", "sops-nix": "sops-nix",
"utils": "utils", "utils": "utils"
"zeroq-credentials": "zeroq-credentials"
} }
}, },
"scss-reset": { "scss-reset": {
@@ -382,11 +489,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1783174389, "lastModified": 1789890976,
"narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=", "narHash": "sha256-GKwH3zpy7tartuJMG0Rv/xUsdetG1QLmTVv8UKgJLmA=",
"owner": "Mic92", "owner": "Mic92",
"repo": "sops-nix", "repo": "sops-nix",
"rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9", "rev": "7214124c20c1542c90deb54af50e2f53ae02711f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -395,24 +502,11 @@
"type": "github" "type": "github"
} }
}, },
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": { "utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": [
"nix-systems"
]
}, },
"locked": { "locked": {
"lastModified": 1731533236, "lastModified": 1731533236,
@@ -428,6 +522,23 @@
"type": "github" "type": "github"
} }
}, },
"z2k": {
"flake": false,
"locked": {
"lastModified": 1789186266,
"narHash": "sha256-d9gg7s66P3pkN7d4l72ryaGC9Ayoqg4tbHaoDNbDTT4=",
"owner": "necronicle",
"repo": "z2k",
"rev": "7beb9754d65a2cafd9c1d26d382bcb61d453d5b3",
"type": "github"
},
"original": {
"owner": "necronicle",
"ref": "z2k-enhanced",
"repo": "z2k",
"type": "github"
}
},
"zapret": { "zapret": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -437,11 +548,11 @@
"zapret-flowseal": "zapret-flowseal" "zapret-flowseal": "zapret-flowseal"
}, },
"locked": { "locked": {
"lastModified": 1784758289, "lastModified": 1788726932,
"narHash": "sha256-gUwiD33Dn5Q4Fi3RwbRmJJ7wiRw19rvyFJBBnhHWMQc=", "narHash": "sha256-MehJgJpN7BGMaDES9I0aj/YG6JkRlSj5RiZDZfclNpc=",
"owner": "kartavkun", "owner": "kartavkun",
"repo": "zapret-discord-youtube", "repo": "zapret-discord-youtube",
"rev": "a7d7f1dd3765937b051d1289fba3fde2a6c8c7c0", "rev": "64a8ee76f4f2e4a8d2751cb732f13448ee1e4fcf",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -453,11 +564,11 @@
"zapret-flowseal": { "zapret-flowseal": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1784697388, "lastModified": 1788121958,
"narHash": "sha256-Lf7oloMkMziQRzyE/nw16J1/hAxSuRG5ioZb7UbLRUo=", "narHash": "sha256-WMpxbtA2OH340e4uuXR0tcUW0D6V9Kzs0KI1iKqkXBM=",
"owner": "Flowseal", "owner": "Flowseal",
"repo": "zapret-discord-youtube", "repo": "zapret-discord-youtube",
"rev": "9503dc045133000af8075e066f09bb469008e530", "rev": "6cec828910d0809863205702182a3557d9d0e8c3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -465,21 +576,6 @@
"repo": "zapret-discord-youtube", "repo": "zapret-discord-youtube",
"type": "github" "type": "github"
} }
},
"zeroq-credentials": {
"locked": {
"lastModified": 1772104025,
"narHash": "sha256-tX5I2lkwbB1leoib6Ao/Et0B1GYrn3vxw4DkFYX8uyM=",
"ref": "refs/heads/master",
"rev": "511fc5446b502ff111020bda6d57261648d62333",
"revCount": 75,
"type": "git",
"url": "ssh://git@github.com/oqyude/zeroq-credentials.git"
},
"original": {
"type": "git",
"url": "ssh://git@github.com/oqyude/zeroq-credentials.git"
}
} }
}, },
"root": "root", "root": "root",
+29 -7
View File
@@ -1,12 +1,10 @@
{ {
description = "oqyude flake"; description = "oqyude flake";
inputs = { inputs = {
# My
zeroq-credentials.url = "git+ssh://git@github.com/oqyude/zeroq-credentials.git"; # flake of creds
# nixpkgs # nixpkgs
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# nixpkgs-last-unstable.url = "github:NixOS/nixpkgs/6b4955211758ba47fac850c040a27f23b9b4008f"; # nixpkgs-master.url = "github:NixOS/nixpkgs/master";
# nixpkgs-last-unstable.url = "github:NixOS/nixpkgs/3497aa5c9457a9d88d71fa93a4a8368816fbeeba";
# nixpkgs-fingerprint.url = "github:NixOS/nixpkgs/nixos-24.11"; # nixpkgs-fingerprint.url = "github:NixOS/nixpkgs/nixos-24.11";
# nix-community # nix-community
@@ -32,13 +30,29 @@
utils.follows = "utils"; utils.follows = "utils";
}; };
}; };
justray = {
url = "github:luynrs/justray";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-parts.follows = "flake-parts";
};
};
utils.url = "github:numtide/flake-utils"; utils = {
url = "github:numtide/flake-utils";
# flake-utils тянет systems (nix-systems/default) сам -> наследуем корневой, чтобы не плодить дубль-узел в flake.lock
inputs.systems.follows = "nix-systems";
};
flake-compat.url = "github:edolstra/flake-compat"; flake-compat.url = "github:edolstra/flake-compat";
nixos-hardware.url = "github:NixOS/nixos-hardware/master"; flake-parts.url = "github:hercules-ci/flake-parts";
nixos-hardware = {
url = "github:NixOS/nixos-hardware/master";
# без follows nixos-hardware лочит свой собственный nixpkgs (две копии в lock/store)
inputs.nixpkgs.follows = "nixpkgs";
};
nix-systems.url = "github:nix-systems/default";
# nixos-facter-modules.url = "github:numtide/nixos-facter-modules"; # nixos-facter-modules.url = "github:numtide/nixos-facter-modules";
# flake-utils.url = "github:numtide/flake-utils"; # flake-utils.url = "github:numtide/flake-utils";
# flake-parts.url = "github:hercules-ci/flake-parts";
# noctalia = { # noctalia = {
# url = "github:noctalia-dev/noctalia-shell"; # url = "github:noctalia-dev/noctalia-shell";
# inputs.nixpkgs.follows = "nixpkgs"; # inputs.nixpkgs.follows = "nixpkgs";
@@ -71,6 +85,14 @@
url = "github:vinceliuice/grub2-themes"; url = "github:vinceliuice/grub2-themes";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
nix-minecraft = {
url = "github:Infinidoge/nix-minecraft";
inputs = {
flake-compat.follows = "flake-compat";
nixpkgs.follows = "nixpkgs";
systems.follows = "nix-systems";
};
};
# nix-index-database = { # nix-index-database = {
# url = "github:nix-community/nix-index-database"; # url = "github:nix-community/nix-index-database";
# inputs.nixpkgs.follows = "nixpkgs"; # inputs.nixpkgs.follows = "nixpkgs";
+16 -23
View File
@@ -9,10 +9,14 @@ let
... ...
}: }:
let let
mkHomeModule = username: { mkUser =
imports = [ username:
(./. + "/${xlib.device.type}.nix") {
]; imports ? [ ],
headless ? false,
}:
{
inherit imports;
home = { home = {
username = username; username = username;
stateVersion = lib.mkDefault "26.05"; stateVersion = lib.mkDefault "26.05";
@@ -21,14 +25,7 @@ let
enableNixpkgsReleaseCheck = false; enableNixpkgsReleaseCheck = false;
}; };
# Headless hosts: no GUI user dirs # Headless hosts: no GUI user dirs
xdg = xdg = lib.mkIf headless {
lib.mkIf
(builtins.elem xlib.device.type [
"server"
"vds"
"wsl"
])
{
enable = true; enable = true;
autostart.enable = true; autostart.enable = true;
userDirs = { userDirs = {
@@ -45,23 +42,19 @@ let
}; };
}; };
}; };
mkRootModule = username: {
home = {
username = username;
stateVersion = lib.mkDefault "26.05";
homeDirectory =
if username == "root" then lib.mkDefault "/${username}" else lib.mkDefault "/home/${username}";
enableNixpkgsReleaseCheck = false;
};
};
in in
{ {
home-manager = { home-manager = {
useGlobalPkgs = true; useGlobalPkgs = true;
useUserPackages = true; useUserPackages = true;
users = { users = {
root = mkRootModule "root"; root = mkUser "root" { };
"${xlib.device.username}" = mkHomeModule xlib.device.username; "${xlib.device.username}" = mkUser xlib.device.username {
imports = [
(./. + "/${xlib.device.type}.nix")
];
headless = xlib.isHeadless;
};
}; };
sharedModules = [ sharedModules = [
inputs.plasma-manager.homeModules.plasma-manager inputs.plasma-manager.homeModules.plasma-manager
+417
View File
@@ -0,0 +1,417 @@
# Declarative OpenCode + oh-my-openagent (oh-my-opencode) plugin setup.
#
# Mirrors ~/.config/opencode/ on the current workstation.
# Imported by home/server.nix (sapphira). Auto-enables programs.opencode.
#
# Files this module owns on disk:
# ~/.config/opencode/opencode.json <- programs.opencode.settings
# ~/.config/opencode/tui.json <- programs.opencode.tui
# ~/.omo/omo.jsonc <- oh-my-openagent plugin's PRIMARY
# runtime config (>= v5.x reads
# only this path; the legacy
# ~/.config/opencode/oh-my-openagent.json
# is read only by the migration shim).
# ~/.config/opencode/oh-my-openagent.json <- legacy mirror, kept so omo doctor
# and any downgrade that re-reads
# the old path see the same content.
#
# Override any field in the importing module if needed.
{
config,
lib,
pkgs,
xlib,
...
}:
let
# Body of ~/.omo/omo.jsonc (and the legacy mirror).
# Loaded by the oh-my-openagent opencode plugin on startup.
#
# Plugins >= 5.x resolve their config from ~/.omo/omo.jsonc, NOT from
# ~/.config/opencode/oh-my-openagent.json. Pinning _migrations here prevents
# the 2026-07-opencode-config-unification migration from running on every
# startup and re-backing-up the file (which would otherwise leave us with
# an empty omo.jsonc that drops every agent override — see the journal entry
# below).
ohMyOpenagentConfig = {
"$schema" =
"https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json";
_migrations = [
"2026-07-opencode-config-unification"
"2026-08-reasoning-unification"
];
agents = {
sisyphus = {
model = "minimax-coding-plan/MiniMax-M3";
variant = "max";
models = [
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
{
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "medium";
}
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
{ model = "opencode/big-pickle"; }
];
};
hephaestus = {
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "medium";
};
oracle = {
model = "minimax-coding-plan/MiniMax-M3";
variant = "xhigh";
models = [
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "high";
}
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "max";
}
];
};
librarian = {
model = "minimax-coding-plan/MiniMax-M3";
};
explore = {
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
models = [
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
"multimodal-looker" = {
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "low";
models = [
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
];
};
prometheus = {
model = "minimax-coding-plan/MiniMax-M3";
variant = "high";
models = [
{
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "high";
}
];
};
metis = {
model = "minimax-coding-plan/MiniMax-M3";
variant = "high";
models = [
{
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "low";
}
];
};
momus = {
model = "minimax-coding-plan/MiniMax-M3";
variant = "xhigh";
models = [
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "max";
}
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "high";
}
];
};
atlas = {
model = "minimax-coding-plan/MiniMax-M3";
models = [
{
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "medium";
}
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
"sisyphus-junior" = {
model = "minimax-coding-plan/MiniMax-M3";
models = [
{
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "medium";
}
{ model = "minimax-coding-plan/MiniMax-M3"; }
{ model = "opencode/big-pickle"; }
];
};
};
categories = {
"visual-engineering" = {
model = "minimax-coding-plan/MiniMax-M3";
variant = "high";
fallback_models = [
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "max";
}
];
};
ultrabrain = {
model = "minimax-coding-plan/MiniMax-M3";
variant = "xhigh";
fallback_models = [
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "high";
}
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "max";
}
];
};
deep = {
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "medium";
fallback_models = [
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "max";
}
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "high";
}
];
};
artistry = {
model = "minimax-coding-plan/MiniMax-M3";
variant = "high";
fallback_models = [
{
model = "minimax-coding-plan/MiniMax-M3";
variant = "max";
}
{
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "high";
}
];
};
quick = {
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
fallback_models = [
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
{ model = "minimax-coding-plan/MiniMax-M3"; }
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
];
};
"unspecified-low" = {
model = "minimax-coding-plan/MiniMax-M3";
fallback_models = [
{
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "medium";
}
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
"unspecified-high" = {
model = "minimax-coding-plan/MiniMax-M3";
fallback_models = [
{
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
variant = "medium";
}
{ model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview"; }
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
writing = {
model = "minimax-coding-plan/MiniMax-M3.1-Flash-Preview";
fallback_models = [
{ model = "minimax-coding-plan/MiniMax-M3"; }
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
};
};
in
let
# nixpkgs ast-grep only ships binary `ast-grep`; omo's ast-grep skill probes
# for `sg` (or ast-grep). Provide both via a symlink wrapper.
astGrepWithSg = pkgs.runCommandLocal "ast-grep-with-sg" { } ''
mkdir -p $out/bin
ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/ast-grep
ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/sg
'';
in
{
programs.opencode = {
enable = true;
# Extras available to opencode-wrapped (via --suffix PATH on the wrapper):
# pkgs.nodejs_22 — npx/npm for MCP servers (webpage-mcp) and omo's plugin loader
# pkgs.ast-grep — `sg` CLI; omo's ast-grep skill requires it (omo doctor)
# pkgs.bun — omo prefers bun; with bun on PATH, `omo doctor` skips node fallback
# pkgs.gh — GitHub CLI; omo's GitHub automation features require it
extraPackages = [
pkgs.nodejs_22
astGrepWithSg
pkgs.bun
pkgs.gh
];
# ~/.config/opencode/opencode.json
settings = {
plugin = [ "oh-my-openagent@latest" ];
mcp = {
webpage = {
type = "local";
command = [
"npx"
"-y"
"-p"
"webpage-mcp@latest"
"webpage-mcp-stdio"
];
};
};
};
# ~/.config/opencode/tui.json
# Mirrors workstation: oh-my-openagent also registered for the TUI.
tui = {
plugin = [ "oh-my-openagent@latest" ];
};
};
# ~/.omo/omo.jsonc — primary file the oh-my-openagent plugin reads at runtime
# (>= v5.x). This path lives outside XDG_CONFIG_HOME (~/.config), so use
# home.file rather than xdg.configFile.
#
# ~/.config/opencode/oh-my-openagent.json is kept as a legacy mirror so
# `omo doctor`, the migration shim, and any future downgrade that re-reads the
# old path see the same content.
#
# MIGRATION TRAP (do not just point back at the legacy path):
# The plugin runs a `2026-07-opencode-config-unification` migration on every
# startup that backs up ~/.omo/omo.jsonc and tries to rewrite it from
# ~/.config/opencode/oh-my-openagent.json. The backup directory name embeds
# the source's content-hashed store path; because HM does not delete the
# previous generation's store path until garbage collection, the same path
# is reused on every retry and omo logs "Migration backup path already
# exists" forever — meanwhile the user's agent overrides disappear and the
# plugin's built-in fallback chain (which references providers like
# kimi-for-coding that opencode's provider registry no longer ships) gets
# picked instead, surfacing as `ProviderModelNotFoundError:
# kimi-for-coding/kimi-for-coding-highspeed` on every subagent spawn.
# Pinning _migrations above makes the migration a no-op; the omo.jsonc
# below is the actual config the plugin sees.
home.file."${config.home.homeDirectory}/.omo/omo.jsonc".text = builtins.toJSON ohMyOpenagentConfig;
xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig;
# Same extras on the user's PATH too, so `omo doctor` and standalone invocations
# of `sg`, `gh`, `bun`, `npm`, `npx` work in the user's shell — not only inside
# the opencode-wrapped binary.
home.packages = [
pkgs.nodejs_22
astGrepWithSg
pkgs.bun
pkgs.gh
];
# Expose `opencode web` as a systemd user service. nginx on sapphira
# proxies https://opencode.zeroq.su -> 127.0.0.1:4096.
#
# --hostname 0.0.0.0 binds the listener to every interface (matches the
# "0.0.0.0" intent; nginx then reverse-proxies 127.0.0.1:4096 internally).
# --cors https://opencode.zeroq.su lets the browser session reach the
# server from that origin without CORS rejection.
#
# SECURITY: with no password, anyone reaching the upstream socket gets full
# opencode. Bind 0.0.0.0 + listener == bridge == shell. The password is
# supplied via sops-managed EnvironmentFile, declared in modules/users.nix
# and decrypted to a path hardcoded here (home-manager modules cannot read
# `config.sops.*` — sops-nix options are NixOS-only).
programs.opencode.web = {
enable = true;
environmentFile = xlib.dirs.opencode-server-env;
extraArgs = [
"--hostname"
"0.0.0.0"
"--cors"
"https://opencode.zeroq.su"
];
};
# RAM constraints for the opencode-web user service.
#
# Sapphira has 5.6 GiB RAM with a ~1 GiB baseline (syncthing + immich + gitea
# + x-ui + nextcloud php-fpm). When something else spikes (immich-ml jobs,
# syncthing indexer, etc.) the system OOM killer activates and picks the
# largest cgroup — opencode at ~260 MiB – 1.4 GiB peak was being chosen and
# systemd then restarted it every few seconds (`RestartSec=5`), masking the
# real cause as a "service crash". The 2026-10-04 incident was exactly this.
#
# Three knobs together make opencode stop being an OOM victim AND stop being
# the source of an OOM:
#
# MemoryHigh soft pressure threshold: kernel reclaims aggressively
# once the cgroup hits this. Process keeps running.
# MemoryMax hard cap: cgroup-local OOM kills Node if exceeded. The
# HOST survives — only this process dies, no restart storm.
# OOMScoreAdjust negative bias for the system-wide OOM killer: opencode
# is killed last, after syncthing/immich/etc.
# OOMPolicy "continue" — systemd does NOT auto-restart on cgroup
# OOM-kill. Without this, a spike triggers the same
# restart-loop the host saw today.
#
# Sizes are derived from observed peak (1.4 GiB at 16:36, 1.1 GiB at 16:59).
# MemoryHigh = 1G gives headroom for normal runs; MemoryMax = 2G caps
# pathological growth. Tweak both together if a workload legitimately
# needs more.
#
# Refs:
# https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#OOMScoreAdjust=
# cgroup/OOM knobs added on top of the [Service] section emitted by
# `programs.opencode.web`. home-manager unions multiple definitions of the
# same systemd unit attrset, so ExecStart/Restart/EnvironmentFile from
# upstream and MemoryHigh/MemoryMax/OOMScoreAdjust/OOMPolicy from here
# land in the same [Service] block systemd actually reads.
#
# NOTE: do NOT use `serviceConfig = { ... }` — home-manager renders that
# as a literal `[serviceConfig]` section, which systemd silently ignores
# (`Unknown section 'serviceConfig'. Ignoring.`). The cgroup protection
# above would never take effect (verified on sapphira, c73a698).
systemd.user.services.opencode-web.Service = {
MemoryHigh = "1G";
MemoryMax = "2G";
OOMScoreAdjust = -900;
OOMPolicy = "continue";
};
# Workaround: home-manager activation updates the GC root `current-home`
# only at the very end (line 358 of the generated activate script), AFTER all
# `home.activation.*` dag entries have run. So we cannot read current-home
# from a dag entry — it still points to the OLD generation at the time our
# script executes. Instead, read `new-home`, which the activator writes
# BEFORE any dag entry runs and which already points at the new generation.
#
# The versioned symlink (`home-manager-NN-link`) is found by following
# `home-manager` one hop rather than hardcoding `home-manager-24-link`,
# so this keeps working across HM major-version bumps.
home.activation.relinkHomeManager = lib.hm.dag.entryAfter [ ] ''
hmVersioned="$(readlink "$HOME/.local/state/nix/profiles/home-manager" 2>/dev/null || true)"
target="$HOME/.local/state/nix/profiles/$hmVersioned"
newGen="$(readlink -e "''${XDG_STATE_HOME:-$HOME/.local/state}/home-manager/gcroots/new-home" 2>/dev/null || true)"
if [[ -n "$hmVersioned" && -n "$newGen" && "$(readlink -f "$target")" != "$newGen" ]]; then
echo "home-manager: relinking $target -> $newGen"
ln -sfn "$newGen" "$target"
fi
'';
}
+1 -1
View File
@@ -32,7 +32,7 @@ in
./modules/dconf.nix ./modules/dconf.nix
./modules/packages.nix ./modules/packages.nix
./modules/plasma-manager.nix ./modules/plasma-manager.nix
./modules/noctalia.nix # ./modules/noctalia.nix
]; ];
xdg = { xdg = {
enable = true; enable = true;
+2 -2
View File
@@ -14,7 +14,7 @@ let
"${config.home.homeDirectory}/Games/PrismLaunchers/${config.home.username}" = "${config.home.homeDirectory}/Games/PrismLaunchers/${config.home.username}" =
".local/share/PrismLauncher"; ".local/share/PrismLauncher";
"${xlib.dirs.lamet-drive}/Users/oqyude/Music" = "Music"; "${xlib.dirs.lamet-drive}/Users/${xlib.device.username}/Music" = "Music";
}; };
mkLinks = lib.mapAttrs' (sourcePath: targetPath: { mkLinks = lib.mapAttrs' (sourcePath: targetPath: {
name = targetPath; name = targetPath;
@@ -27,7 +27,7 @@ in
./modules/dconf.nix ./modules/dconf.nix
./modules/packages.nix ./modules/packages.nix
./modules/plasma-manager.nix ./modules/plasma-manager.nix
./modules/noctalia.nix # ./modules/noctalia.nix
]; ];
xdg = { xdg = {
enable = true; enable = true;
+4 -10
View File
@@ -5,20 +5,14 @@
xlib, xlib,
... ...
}: }:
let
symlinksPaths = {
"${config.home.homeDirectory}/External/Music" = "Music";
};
mkLinks = lib.mapAttrs' (sourcePath: targetPath: {
name = targetPath;
value.source = config.lib.file.mkOutOfStoreSymlink "${sourcePath}";
}) symlinksPaths;
in
{ {
imports = [ imports = [
./minimal.nix ./minimal.nix
./modules/opencode.nix
]; ];
home.file = mkLinks; home.file = xlib.helpers.mkSymlinks config {
"${config.home.homeDirectory}/External/Music" = "Music";
};
home.activation = { home.activation = {
yaziSync = '' yaziSync = ''
${pkgs.rsync}/bin/rsync -Lrv --no-A --no-X "${config.home.homeDirectory}/.config/yazi/" "${xlib.dirs.storage}/yazi/" ${pkgs.rsync}/bin/rsync -Lrv --no-A --no-X "${config.home.homeDirectory}/.config/yazi/" "${xlib.dirs.storage}/yazi/"
+8 -8
View File
@@ -218,7 +218,7 @@
enable = true; enable = true;
settings = { settings = {
user = { user = {
name = "oqyude"; name = xlib.device.username;
email = "oqyude@gmail.com"; email = "oqyude@gmail.com";
}; };
pull = { pull = {
@@ -250,31 +250,31 @@
}; };
sapphira = { sapphira = {
HostName = "192.168.1.20"; HostName = "192.168.1.20";
User = "oqyude"; User = xlib.device.username;
}; };
sapphira-tailscale = { sapphira-tailscale = {
HostName = "100.64.0.0"; HostName = "100.64.0.0";
User = "oqyude"; User = xlib.device.username;
}; };
otreca-old = { otreca-old = {
HostName = "217.60.3.12"; HostName = "217.60.3.12";
User = "oqyude"; User = xlib.device.username;
}; };
otreca = { otreca = {
HostName = "109.248.161.5"; HostName = "109.248.161.5";
User = "oqyude"; User = xlib.device.username;
}; };
otreca-tailscale = { otreca-tailscale = {
HostName = "100.64.1.0"; HostName = "100.64.1.0";
User = "oqyude"; User = xlib.device.username;
}; };
rydiwo = { rydiwo = {
HostName = "192.168.1.102"; HostName = "192.168.1.102";
User = "oqyude"; User = xlib.device.username;
}; };
epral = { epral = {
HostName = "192.168.1.101"; HostName = "192.168.1.101";
User = "oqyude"; User = xlib.device.username;
Port = 8022; Port = 8022;
}; };
}; };
+5 -12
View File
@@ -5,23 +5,16 @@
xlib, xlib,
... ...
}: }:
let
symlinksPaths = {
"${config.home.homeDirectory}/External/Music" = "Music";
"${xlib.dirs.wsl-home}" = "External";
"${xlib.dirs.wsl-storage}" = "Storage";
};
mkLinks = lib.mapAttrs' (sourcePath: targetPath: {
name = targetPath;
value.source = config.lib.file.mkOutOfStoreSymlink "${sourcePath}";
}) symlinksPaths;
in
{ {
imports = [ imports = [
./apps ./apps
./minimal.nix ./minimal.nix
]; ];
home.file = mkLinks; home.file = xlib.helpers.mkSymlinks config {
"${config.home.homeDirectory}/External/Music" = "Music";
"${xlib.dirs.wsl-home}" = "External";
"${xlib.dirs.wsl-storage}" = "Storage";
};
home.activation = { home.activation = {
yaziSync = '' yaziSync = ''
${pkgs.rsync}/bin/rsync -Lrv "${config.home.homeDirectory}/.config/yazi/" "${xlib.dirs.wsl-storage}/yazi/" ${pkgs.rsync}/bin/rsync -Lrv "${config.home.homeDirectory}/.config/yazi/" "${xlib.dirs.wsl-storage}/yazi/"
+14 -12
View File
@@ -2,26 +2,28 @@
inputs, inputs,
... ...
}: }:
# Builds a NixOS system from a host record.
#
# `xlib` is the pure host value (lib/xlib.nix `mkXlib`) built in
# configurations/default.nix. It is handed to every module as the `xlib`
# argument, so modules read plain `xlib.*` data instead of `config.xlib.*`
# and the host record stays the single source of truth.
{ {
deviceType, xlib,
hostname ? null,
modules ? [ ], modules ? [ ],
system ? "x86_64-linux", system ? "x86_64-linux",
...
}: }:
let let
lib = inputs.nixpkgs.lib; lib = inputs.nixpkgs.lib;
in in
lib.nixosSystem { lib.nixosSystem {
inherit system; inherit
modules = modules ++ [ system
{ modules
xlib.device = { ;
type = deviceType;
}
// lib.optionalAttrs (hostname != null) { inherit hostname; };
}
];
specialArgs = { specialArgs = {
inherit deviceType inputs; inherit inputs;
inherit xlib;
}; };
} }
+78
View File
@@ -0,0 +1,78 @@
# Pure host library: no module system involved.
#
# Aggregates the four concerns a host record is built from:
# device.nix identity + capability flags from the device type
# dirs.nix well-known paths, derived from username
# helpers.nix pure helper functions shared by modules
#
# `mkXlib` is called in flake-level code (configurations/default.nix) and
# handed to every module as the `xlib` argument via lib/mkSystem.nix, so
# modules read plain `xlib.*` values instead of `config.xlib.*` and nothing in
# xlib can be overridden per host — the host record is the only place to
# change it.
{
lib,
...
}:
let
inherit (import ./device.nix { inherit lib; })
devices
mkDevice
;
# dirs.nix is itself a function of `username`, not an attrset.
mkDirs = import ./dirs.nix;
helpers = (import ./helpers.nix { inherit lib; });
in
{
inherit
devices
helpers
mkDevice
mkDirs
;
# Full host record: identity + capability flags + well-known paths +
# shared helpers.
mkXlib =
{
hostname,
type,
username ? "oqyude",
uid ? 1000,
gid ? 1000,
}:
let
device = mkDevice {
inherit
hostname
type
username
uid
gid
;
};
in
{
device = {
inherit
hostname
type
username
uid
gid
;
};
isDesktop = device.isDesktop;
isHeadless = device.isHeadless;
dirs = mkDirs username;
# Bind the host's ids into the mount helpers, so ntfs3/exfat options
# carry the same uid/gid the primary user actually has.
helpers = import ./helpers.nix {
inherit lib;
uid = device.uid;
gid = device.gid;
};
};
}
+75
View File
@@ -0,0 +1,75 @@
{
lib,
...
}:
# Supported device types and the identity record built from one.
#
# Single source of truth for host identity: hostname, type, username and the
# capability flags derived from the type. Replaces the old `lib.types.enum`
# in modules/options.nix and the hand-written type lists in modules/default.nix
# and home/home.nix.
let
devices = {
minimal = {
desktop = false;
headless = false;
};
primary = {
desktop = true;
headless = false;
};
secondary = {
desktop = true;
headless = false;
};
server = {
desktop = false;
headless = true;
};
vds = {
desktop = false;
headless = true;
};
wsl = {
desktop = false;
headless = true;
};
termux = {
desktop = false;
headless = true;
};
};
in
{
inherit devices;
# Unknown device type fails here, at flake level, with the valid list.
mkDevice =
{
hostname,
type,
username ? "oqyude",
# The primary user is pinned to 1000 rather than left to NixOS'
# nextfree logic: the mount helpers below write uid=/gid= into
# ntfs3/exfat options, and an NTFS/exFAT volume mounted with a
# different id shows every file as owned by `nobody`.
uid ? 1000,
gid ? 1000,
}:
let
capabilities =
devices.${type}
or (throw "xlib: unknown device type '${type}', expected one of ${lib.concatStringsSep ", " (builtins.attrNames devices)}");
in
{
inherit
hostname
type
username
uid
gid
;
isDesktop = capabilities.desktop;
isHeadless = capabilities.headless;
};
}
+36
View File
@@ -0,0 +1,36 @@
# Well-known paths. Everything derives from `username`, which is why the
# whole set can be computed outside the module system.
username:
let
user-home = "/home/${username}";
wsl-home = "/mnt/c/Users/${username}";
server-home = "${user-home}/External";
services-mnt-folder = "/mnt/services";
in
{
inherit
user-home
wsl-home
server-home
services-mnt-folder
;
opencode-server-env = "${user-home}/.config/opencode/server.env";
user-storage = "${user-home}/Storage";
wsl-storage = "${wsl-home}/Storage";
server-credentials = "${server-home}/Credentials/server";
storage = "${server-home}/Storage";
calibre-library = "${server-home}/Books-Library";
services-folder = "${server-home}/Services";
services-nodes-folder = "${services-mnt-folder}/nodes";
postgresql-folder = "${services-mnt-folder}/postgresql";
authelia-folder = "${services-mnt-folder}/authelia";
music-library = "${user-home}/Music";
archive-drive = "/mnt/archive";
lamet-drive = "/mnt/lamet";
mobile-drive = "/mnt/mobile";
therima-drive = "/mnt/therima";
vetymae-drive = "/mnt/vetymae";
soptur-drive = "/mnt/soptur";
}
+161
View File
@@ -0,0 +1,161 @@
{
lib,
# The primary user's ids, bound from xlib.device by mkXlib. ntfs3/exfat
# volumes carry POSIX ids, so a mount using anything other than the real
# uid/gid shows every file as owned by `nobody`.
uid,
gid,
...
}:
# Pure helper functions for module definitions.
# Injected into every module via `xlib.helpers` (see default.nix).
#
# Defined in a `let` because they reference each other (mkTmpDirs uses
# mkTmpfile, mkServiceStorage uses mkTmpDirs + mkSystemdBind).
let
# tmpfiles rule: "type dir mode user group -"
mkTmpfile =
type: dir: mode: user: group:
"${type} ${dir} ${mode} ${user} ${group} -";
# several tmpfiles types for the same dir, e.g. ["d" "z"] or ["d" "Z"]
mkTmpDirs =
{
dir,
mode,
user,
group,
types ? [
"d"
"z"
],
}:
map (type: mkTmpfile type dir mode user group) types;
# fileSystems bind mount
mkBindMount =
{
what,
where,
}:
{
"${where}" = {
device = what;
fsType = "none";
options = [
"bind"
"nofail"
];
};
};
# systemd.mounts bind mount (automount variant)
mkSystemdBind =
{
what,
where,
}:
{
enable = true;
options = "bind,x-systemd.automount,nofail";
requires = [ "local-fs.target" ];
type = "none";
wantedBy = [ "multi-user.target" ];
inherit what where;
};
# Full "service storage" block: services-mnt source dir + /var/lib target,
# tmpfiles d/z + automount bind. Used as:
# storage = xlib.helpers.mkServiceStorage { name = "x"; user = "x"; group = "x"; };
# systemd = storage.systemd;
mkServiceStorage =
{
name,
user,
group,
mode ? "0755",
target ? "/var/lib/${name}",
base ? "/mnt/services",
}:
let
sourceDir = "${base}/${name}";
in
{
inherit sourceDir target;
systemd = {
tmpfiles.rules = mkTmpDirs {
dir = sourceDir;
inherit mode user group;
};
mounts = [
(mkSystemdBind {
what = sourceDir;
where = target;
})
];
};
};
# ntfs3 mount, e.g. fileSystems = mkNtfsMount { path = ...; uuid = ...; }
mkNtfsMount =
{
path,
uuid,
mask ? "0007",
enable ? null,
}:
{
"${path}" = {
device = "/dev/disk/by-uuid/${uuid}";
fsType = "ntfs3";
options = [
"defaults"
"uid=${toString uid}"
"gid=${toString gid}"
"fmask=${mask}"
"dmask=${mask}"
"nofail"
];
}
// lib.optionalAttrs (enable != null) { inherit enable; };
};
# exfat mount, e.g. fileSystems = mkExfatMount { path = ...; uuid = ...; }
mkExfatMount =
{
path,
uuid ? null,
label ? null,
}:
{
"${path}" = {
device = if uuid != null then "/dev/disk/by-uuid/${uuid}" else "/dev/disk/by-label/${label}";
fsType = "exfat";
options = [
"nofail"
"uid=${toString uid}"
"gid=${toString gid}"
];
};
};
# home-manager out-of-store symlinks: path = source (target name = attr name)
mkSymlinks =
config: paths:
lib.mapAttrs' (sourcePath: targetPath: {
name = targetPath;
value.source = config.lib.file.mkOutOfStoreSymlink "${sourcePath}";
}) paths;
in
{
inherit
mkTmpfile
mkTmpDirs
mkBindMount
mkSystemdBind
mkServiceStorage
mkNtfsMount
mkExfatMount
mkSymlinks
;
}
+60 -65
View File
@@ -7,8 +7,38 @@
}: }:
let let
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/3x-ui"; panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/3x-ui";
# Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/) gets mounted
# read-only into the 3x-ui container so the panel can terminate TLS itself.
# Null when 3x-ui serves plain HTTP and TLS is terminated by an upstream
# nginx.
certDomain = config.host."3x-ui".certDomain;
certMounts =
if certDomain == null then
[ ]
else
# LE cert mounted read-only so 3x-ui can terminate TLS itself.
# The 3x-ui settings table must point webCertFile / webKeyFile at
# /root/cert/fullchain.pem and /root/cert/key.pem.
map (f: "/var/lib/acme/${certDomain}/${f}:/root/cert/${f}:ro") [
"fullchain.pem"
"key.pem"
];
basePorts = [
# Local-only upstreams for the 3x-ui panel and subscription endpoint.
# The direct Xray inbound remains publicly reachable on 8443.
"127.0.0.1:2049:2049/tcp"
"127.0.0.1:2096:2096/tcp"
"0.0.0.0:8443:8443/tcp"
];
# VDS-only: nginx stream forwards host:443 → 127.0.0.1:15380 →
# container:443, so Xray sees its REALITY inbound on port 443.
realityPorts = lib.optional config.host."3x-ui".reality443Forwarding "127.0.0.1:15380:443/tcp";
in in
{ {
# `host."3x-ui"` options are declared in modules/options.nix: they are set
# by modules/server and modules/vds, so this module cannot be the only place
# that knows they exist.
config = {
virtualisation = { virtualisation = {
podman = { podman = {
enable = true; enable = true;
@@ -30,11 +60,12 @@ in
volumes = [ volumes = [
"${panel}/cert/:/root/cert:rw" "${panel}/cert/:/root/cert:rw"
"${panel}/db/:/etc/x-ui:rw" "${panel}/db/:/etc/x-ui:rw"
]; ]
++ certMounts;
log-driver = "journald"; log-driver = "journald";
extraOptions = [ # Adding a new inbound through the 3x-ui panel on a port outside
"--network=host" # the 14380-15379 range requires extending basePorts and rebuilding.
]; ports = basePorts ++ realityPorts;
}; };
}; };
}; };
@@ -42,21 +73,12 @@ in
systemd = { systemd = {
services = { services = {
"podman-3xui_app" = { "podman-3xui_app" = {
serviceConfig = { serviceConfig.Restart = lib.mkOverride 90 "always";
Restart = lib.mkOverride 90 "always"; partOf = [ "podman-compose-3x-ui-root.target" ];
wantedBy = [ "podman-compose-3x-ui-root.target" ];
}; };
partOf = [
"podman-compose-3x-ui-root.target"
];
wantedBy = [
"podman-compose-3x-ui-root.target"
];
};
# Update
"podman-update-3xui_app" = { "podman-update-3xui_app" = {
path = [ path = [ pkgs.podman ];
pkgs.podman
];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
TimeoutSec = 300; TimeoutSec = 300;
@@ -66,64 +88,36 @@ in
systemctl restart podman-3xui_app.service systemctl restart podman-3xui_app.service
''; '';
}; };
# Builds
# "podman-build-3xui_app" = {
# path = [
# pkgs.podman
# pkgs.git
# ];
# serviceConfig = {
# Type = "oneshot";
# TimeoutSec = 300;
# };
# script = ''
# cd /mnt/containers/3x-ui
# podman build -t compose2nix/3xui_app -f ./Dockerfile .
# '';
# };
}; };
# Root service # Starts/stops together with all 3x-ui compose resources.
# When started, this will automatically create all resources and start
# the containers. When stopped, this will teardown all resources.
targets."podman-compose-3x-ui-root" = { targets."podman-compose-3x-ui-root" = {
unitConfig = { unitConfig.Description = "Root target generated by compose2nix.";
Description = "Root target generated by compose2nix.";
};
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
}; };
timers."podman-update-3xui_app" = { # timers."podman-update-3xui_app" = {
wantedBy = [ "timers.target" ]; # wantedBy = [ "timers.target" ];
timerConfig = { # timerConfig = {
OnCalendar = "weekly"; # OnCalendar = "weekly";
Persistent = true; # Persistent = true;
}; # };
}; # };
# Folders
tmpfiles.rules = [ tmpfiles.rules = [
"d ${xlib.dirs.services-mnt-folder} 0755 root root -" (xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
"d ${xlib.dirs.services-nodes-folder} 0755 root root -" (xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root")
"d ${xlib.dirs.services-nodes-folder}/${xlib.device.hostname} 0755 root root -" (xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
"d ${panel} 0755 root root -" "root"
"d ${panel}/db 0755 root root -" "root"
"d ${panel}/cert 0755 root root -" )
"Z ${panel} 0755 root root -" (xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${panel}/db" "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${panel}/cert" "0755" "root" "root")
# Relabel panel dir for SELinux so containers can access it.
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
]; ];
}; };
# Enable container name DNS for all Podman networks. # Enable container name DNS for all Podman networks.
networking.firewall = { networking.firewall = {
allowedUDPPortRanges = [
{
from = 14380;
to = 15380;
}
];
allowedTCPPortRanges = [
{
from = 14380;
to = 15380;
}
];
interfaces = interfaces =
let let
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*"; matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
@@ -132,4 +126,5 @@ in
"${matchAll}".allowedUDPPorts = [ 53 ]; "${matchAll}".allowedUDPPorts = [ 53 ];
}; };
}; };
};
} }
+116
View File
@@ -0,0 +1,116 @@
{
lib,
pkgs,
...
}:
let
# The image is built here rather than pulled: zaakirio/kokoro-ru is a
# Hugging Face repo, not a published OCI image, and its Russian G2P has to be
# driven through the repo's own ru_g2p.py.
#
# The build context goes through the store so the image is pinned to the
# config revision: edit a file, `nixos-rebuild`, and the unit below rebuilds
# and restarts. Reading the context off a checkout at runtime would leave the
# running container untraceable back to any config.
#
# runCommand rather than linkFarm: linkFarm entries are symlinks into other
# store paths, and `podman build` only mounts the context root, so every COPY
# fails with "copier: get: lstat ...: no such file or directory". Copying the
# bytes in leaves the context with no symlinks that escape its root.
source = pkgs.runCommand "kokoro-tts-source" { } ''
mkdir -p "$out"
cp -L ${./kokoro-tts/Dockerfile} "$out/Dockerfile"
cp -L ${./kokoro-tts/app.py} "$out/app.py"
cp -L ${./kokoro-tts/fetch_assets.py} "$out/fetch_assets.py"
cp -L ${./kokoro-tts/requirements.txt} "$out/requirements.txt"
'';
image = "localhost/kokoro-tts:latest";
# Unchanged from the silero module, so whatever already points at
# http://127.0.0.1:9898/v1 keeps working without edits.
hostPort = 9898;
containerPort = 8000;
in
{
config = {
virtualisation = {
podman = {
enable = true;
autoPrune = {
enable = true;
flags = [ "--all" ];
};
dockerCompat = true;
};
oci-containers = {
backend = "podman";
containers.kokoro-tts = {
image = image;
ports = [
"127.0.0.1:${toString hostPort}:${toString containerPort}"
];
environment = {
# Inference is CPU-bound and already threaded inside torch. Measured
# on a 24-logical-core host: median end-to-end latency for a 5.6 s
# utterance was 1.203 s at 4 threads, 0.979 s at 12, 0.980 s at 16
# and 1.87 s at 24, so the useful ceiling is the physical core count
# and oversubscribing it roughly doubles the wait. These three must
# stay equal to the Dockerfile ENV and the app.py default: whichever
# of the three is set wins over the others.
KOKORO_THREADS = "12";
OMP_NUM_THREADS = "12";
MKL_NUM_THREADS = "12";
TZ = "Europe/Moscow";
};
# No volumes: the checkpoints, the acute-aware espeak data and
# ruaccent's ONNX models are all baked into the image, so the
# container needs neither a host directory nor the network to start.
log-driver = "journald";
};
};
};
systemd = {
services = {
# Runs before the container. BuildKit caches the expensive layers, so
# on every boot after the first this is a no-op that still verifies the
# image exists.
"podman-build-kokoro-tts" = {
path = [ pkgs.podman ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# First build pulls torch wheels plus ~700 MB of weights.
TimeoutSec = 3600;
};
script = ''
podman build -t ${image} ${source}
'';
wantedBy = [ "multi-user.target" ];
};
"podman-kokoro-tts" = {
# The image does not exist until the build above ran, and a `latest`
# tag must be re-pulled on rebuild, so ordering has to be explicit.
after = [ "podman-build-kokoro-tts.service" ];
requires = [ "podman-build-kokoro-tts.service" ];
serviceConfig.Restart = lib.mkOverride 90 "always";
# Auto-start disabled: start manually with `systemctl start podman-kokoro-tts`.
wantedBy = [ ];
};
};
};
};
}
+68
View File
@@ -0,0 +1,68 @@
# Fully qualified on purpose: NixOS ships a podman registries.conf without
# unqualified-search-registries, so a bare "python:3.12-slim-bookworm" fails to
# resolve before the build even starts.
FROM docker.io/library/python:3.12-slim-bookworm
# Pinned, not "main": a rebuild that only touched the Nix module must not
# silently pick up different weights. Bump these deliberately.
ARG KOKORO_RU_REPO=zaakirio/kokoro-ru
ARG KOKORO_RU_REVISION=d649c57b239b18c4c384378127cbf01dba039bc1
# Trim to "sveta" to halve the image: masha shares her checkpoint and dima is
# a second 327 MB one.
ARG KOKORO_RU_VOICES=sveta,masha,dima
# Thread counts, not a guess: see app.py THREADS. 12 was the measured plateau on
# a 24-logical-core host, and 24 was ~2x worse. Must stay equal to the Nix
# module's environment.environment, which wins over this ENV.
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
HF_HUB_DISABLE_TELEMETRY=1 \
HF_HUB_DISABLE_SYMLINKS_WARNING=1 \
KOKORO_RU_REPO=${KOKORO_RU_REPO} \
KOKORO_RU_REVISION=${KOKORO_RU_REVISION} \
KOKORO_RU_VOICES=${KOKORO_RU_VOICES} \
KOKORO_MODEL_DIR=/app/kokoro-ru \
KOKORO_THREADS=12 \
OMP_NUM_THREADS=12 \
MKL_NUM_THREADS=12 \
TZ=Europe/Moscow
WORKDIR /app
# libgomp1 is torch's OpenMP runtime. espeak-ng comes from the espeakng-loader
# wheel rather than the distro package because the model needs its own
# recompiled ru_dict, and libsndfile is absent because WAV/PCM are written with
# stdlib `wave` while every other format goes through imageio-ffmpeg.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# CPU-only torch from its own index: the default PyPI wheel drags in ~2.5 GB of
# CUDA libraries for a machine that has no GPU.
RUN pip install --index-url https://download.pytorch.org/whl/cpu torch
COPY requirements.txt ./
RUN pip install -r requirements.txt
# fetch_assets.py is copied on its own and app.py only after the snapshot, never
# as one COPY. A single COPY would tie the 639 MB download to the application
# source: any edit to app.py would invalidate this layer and refetch every
# checkpoint as hundreds of anonymous, rate-limited requests.
COPY fetch_assets.py ./
# Bakes the checkpoints, the acute-aware espeak data and ruaccent's ONNX models
# into the layer, which is what lets the container start with no network and no
# writable volume.
RUN python fetch_assets.py
COPY app.py ./
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=4)"]
# No workers: the model is a shared in-process singleton, so a second worker
# would only mean a second copy of ~2 GB of weights.
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
+585
View File
@@ -0,0 +1,585 @@
"""OpenAI-compatible TTS API backed by zaakirio/kokoro-ru.
The model itself is language-blind: phoneme ids in, 24 kHz audio out. All the
Russian lives in the G2P front-end, and the one that matters is kokoro-ru's own
`ru_g2p.py` — RUAccent resolves lexical stress, ё and homographs, then an
acute-aware espeak-ng phonemizer turns that into IPA. Stock misaki Russian is
espeak-only and gets stress wrong often enough that the model reads as
non-native (measured 27% vs 22% round-trip WER, per the model card).
So: text -> RuG2P.phonemize -> KModel(ipa, voicepack[len(ipa) - 1]) -> waveform.
Endpoints
POST /v1/audio/speech OpenAI text-to-speech
POST /v1/audio/speech/stream same, but mp3/opus emitted while synthesising
GET /v1/models OpenAI model list
GET /v1/voices voice inventory (extension, not part of OpenAI)
GET /healthz readiness, 503 until the model is loaded
"""
from __future__ import annotations
import io
import logging
import os
import queue
import re
import subprocess
import sys
import threading
import wave
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Iterator, Literal
import numpy as np
from fastapi import FastAPI
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING: # torch is imported lazily so /healthz answers during boot
import torch
MODEL_ID = "kokoro-ru"
SAMPLE_RATE = 24000
MODEL_DIR = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
DEFAULT_VOICE = os.environ.get("KOKORO_DEFAULT_VOICE", "sveta")
# Measured on the host this was tuned for (Ryzen AI 9 HX 370, 24 logical cores):
# median end-to-end latency for a 5.6 s utterance was 1.203 s @ 4 threads,
# 1.066 s @ 8, 0.979 s @ 12, 0.980 s @ 16, then 1.87 s @ 24. The gain stops at
# the physical core count and SMT oversubscription costs ~2x, so cap instead of
# trusting os.cpu_count(), which reports logical CPUs. Override on other hosts.
THREADS = int(os.environ.get("KOKORO_THREADS", min(12, os.cpu_count() or 4)))
# 2026-07-29, when the kokoro-ru revision we pin was published. Clients that
# cache on this treat any change as a new model, so it must stay stable.
MODEL_CREATED = 1785353253
# voice -> (checkpoint stem, gender). The checkpoint carries the timbre and the
# voicepack the identity, which is why sveta and masha share one file.
VOICE_SPECS: dict[str, tuple[str, str]] = {
"sveta": ("kokoro-ru-v2-base", "female"),
"masha": ("kokoro-ru-v2-base", "female"),
"dima": ("kokoro-ru-v2-dima", "male"),
}
# Clients that ship the OpenAI voice list (alloy, nova, echo, ...) send those
# names unless the user overrides them, so map them onto the three we have.
VOICE_ALIASES: dict[str, str] = {
"alloy": "sveta",
"ash": "sveta",
"ballad": "sveta",
"verse": "sveta",
"marin": "sveta",
"coral": "masha",
"sage": "masha",
"shimmer": "masha",
"cedar": "masha",
"echo": "dima",
"fable": "dima",
"onyx": "dima",
}
CONTENT_TYPES = {
"wav": "audio/wav",
"mp3": "audio/mpeg",
"opus": "audio/ogg",
"aac": "audio/aac",
"flac": "audio/flac",
"pcm": "audio/pcm",
}
# Everything except wav and pcm goes through ffmpeg; those two are byte-exact
# from the stdlib and need no encoder at all.
FFMPEG_ARGS = {
"mp3": ["-c:a", "libmp3lame", "-q:a", "2"],
"opus": ["-c:a", "libopus", "-b:a", "64k"],
"aac": ["-c:a", "aac", "-b:a", "128k"],
"flac": ["-c:a", "flac"],
}
FFMPEG_CONTAINERS = {"mp3": "mp3", "opus": "ogg", "aac": "adts", "flac": "flac"}
# Kokoro's Albert context is 510 tokens and KModel.forward asserts
# len(ids) + 2 <= 510, so 508 phonemes is the hard ceiling per forward pass.
MAX_PHONEMES = 508
# Roughly 300 characters of Russian lands near 400 phonemes, comfortably under
# the ceiling, and keeps a chunk short enough that a bad sentence is a short
# chunk.
CHUNK_CHARS = 300
# Silence inserted between chunks. Without it the concatenation clicks at every
# boundary because each forward pass starts and ends on a zero crossing.
CHUNK_GAP_S = 0.08
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("kokoro-ru")
def split_text(text: str, budget: int = CHUNK_CHARS) -> list[str]:
"""Split into sentence-bounded chunks, hard-cutting only as a last resort.
Phonemizing per sentence rather than per paragraph keeps RUAccent's stress
decisions local and gives the model a reset point at every full stop.
"""
chunks: list[str] = []
current = ""
for sentence in _SENTENCE_SPLIT.split(text.strip()):
sentence = sentence.strip()
while len(sentence) > budget:
if current:
chunks.append(current)
current = ""
chunks.append(sentence[:budget])
sentence = sentence[budget:].strip()
if not sentence:
continue
if len(current) + len(sentence) + 1 > budget:
# Guarded: when the first sentence fills the budget exactly, or the
# previous one was hard-cut down to nothing, `current` is empty and
# a bare append would queue a zero-length chunk.
if current:
chunks.append(current)
current = sentence
else:
current = f"{current} {sentence}".strip()
if current:
chunks.append(current)
return chunks
def split_phonemes(ps: str, limit: int = MAX_PHONEMES) -> list[str]:
"""Cut an over-long phoneme string on word boundaries."""
if len(ps) <= limit:
return [ps]
parts: list[str] = []
rest = ps
while len(rest) > limit:
cut = rest.rfind(" ", 0, limit)
if cut <= 0:
cut = limit
parts.append(rest[:cut].strip())
rest = rest[cut:].strip()
if rest:
parts.append(rest)
return [part for part in parts if part]
class KokoroRu:
"""Loaded model plus the G2P front-end, behind a single inference lock."""
def __init__(self) -> None:
self._torch: torch | None = None
self._g2p = None
self._models: dict[str, torch.nn.Module] = {}
self._packs: dict[str, torch.Tensor] = {}
# The Albert encoder and the iSTFTNet decoder keep per-call scratch
# buffers; concurrent forwards on one model interleave into them. The
# model is fast enough on CPU that serialising is not the bottleneck.
self._lock = threading.Lock()
def load(self) -> None:
import torch
from kokoro import KModel
torch.set_num_threads(THREADS)
self._torch = torch
# RuG2P is imported from the baked snapshot, not installed, and it
# resolves espeak-data/ plus kokoro-config.json next to itself.
sys.path.insert(0, str(MODEL_DIR))
from ru_g2p import RuG2P
self._g2p = RuG2P(
espeak_data=MODEL_DIR / "espeak-data",
vocab_path=MODEL_DIR / "kokoro-config.json",
)
for stem in sorted({stem for stem, _ in VOICE_SPECS.values()}):
checkpoint = MODEL_DIR / f"{stem}.pth"
if not checkpoint.exists():
log.warning("checkpoint %s missing, voices using it stay unavailable", checkpoint)
continue
# repo_id is only used to build the default model filename; passing
# both config and model keeps it from touching the HF cache at all.
self._models[stem] = KModel(
repo_id=str(MODEL_DIR),
config=str(MODEL_DIR / "config.json"),
model=str(checkpoint),
).eval()
log.info("loaded checkpoint %s", checkpoint.name)
for name in VOICE_SPECS:
pack = MODEL_DIR / "voices" / f"{name}.pt"
if pack.exists():
self._packs[name] = torch.load(str(pack), map_location="cpu", weights_only=True)
if not self.available_voices():
raise RuntimeError(f"no usable voices under {MODEL_DIR}")
def available_voices(self) -> list[str]:
return [
name
for name in VOICE_SPECS
if name in self._packs and VOICE_SPECS[name][0] in self._models
]
def phonemes(self, text: str):
for chunk in split_text(text):
ps, _oov = self._g2p.phonemize(chunk)
ps = ps.strip()
if ps:
yield from split_phonemes(ps)
def iter_audio_chunks(self, text: str, voice: str, speed: float):
"""Yields float32 audio per phoneme chunk, silence gaps interleaved.
The engine lock is held for the whole iteration, so a caller that stops
consuming early releases synthesis for everyone else.
"""
torch = self._torch
assert torch is not None, "synthesize() before load()"
stem, _gender = VOICE_SPECS[voice]
model = self._models[stem]
pack = self._packs[voice]
gap = np.zeros(int(CHUNK_GAP_S * SAMPLE_RATE), dtype=np.float32)
with self._lock:
for index, ps in enumerate(self.phonemes(text)):
# The style vector is picked by phoneme-string length, which is
# why the model sounds deterministic for identical text.
style = pack[len(ps) - 1]
# The packs ship as [510, 256]; KModel wants a batch of one.
if style.dim() == 1:
style = style.unsqueeze(0)
if index:
yield gap
yield np.asarray(
model(ps, style, speed, return_output=True).audio,
dtype=np.float32,
).reshape(-1)
def synthesize(self, text: str, voice: str, speed: float) -> np.ndarray:
chunks = list(self.iter_audio_chunks(text, voice, speed))
if not chunks:
return np.zeros(0, dtype=np.float32)
return np.concatenate(chunks)
def encode(audio: np.ndarray, fmt: str) -> bytes:
clipped = np.clip(audio, -1.0, 1.0)
if fmt == "pcm":
# OpenAI's pcm is raw signed 16-bit little-endian mono at 24 kHz.
return (clipped * 32767.0).astype("<i2").tobytes()
buffer = io.BytesIO()
with wave.open(buffer, "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(SAMPLE_RATE)
out.writeframes((clipped * 32767.0).astype("<i2").tobytes())
wav = buffer.getvalue()
if fmt == "wav":
return wav
import imageio_ffmpeg
command = [
imageio_ffmpeg.get_ffmpeg_exe(),
"-hide_banner",
"-loglevel",
"error",
"-i",
"pipe:0",
"-ar",
str(SAMPLE_RATE),
"-ac",
"1",
*FFMPEG_ARGS[fmt],
"-f",
FFMPEG_CONTAINERS[fmt],
"pipe:1",
]
done = subprocess.run(command, input=wav, capture_output=True, check=False)
if done.returncode != 0:
raise RuntimeError(done.stderr.decode("utf-8", "replace").strip()[-400:])
return done.stdout
class StreamEncoder:
"""One long-lived ffmpeg per request: raw PCM in, encoded bytes out.
A single process is what keeps the container valid. Handing it the audio in
pieces as they are synthesised avoids any byte-level concatenation, whereas
encoding the pieces separately and joining the results would emit chained
Ogg for opus, which plenty of players reject.
"""
def __init__(self, fmt: str) -> None:
import imageio_ffmpeg
self._proc = subprocess.Popen(
[
imageio_ffmpeg.get_ffmpeg_exe(),
"-hide_banner",
"-loglevel",
"error",
"-f",
"s16le",
"-ar",
str(SAMPLE_RATE),
"-ac",
"1",
"-i",
"pipe:0",
*FFMPEG_ARGS[fmt],
"-f",
FFMPEG_CONTAINERS[fmt],
"pipe:1",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self._blocks: queue.Queue[bytes | None] = queue.Queue()
self._reader = threading.Thread(target=self._pump, daemon=True)
self._reader.start()
def _pump(self) -> None:
assert self._proc.stdout is not None
while True:
block = self._proc.stdout.read(8192)
if not block:
break
self._blocks.put(block)
self._blocks.put(None)
def push(self, audio: np.ndarray) -> None:
assert self._proc.stdin is not None
clipped = np.clip(audio, -1.0, 1.0)
self._proc.stdin.write((clipped * 32767.0).astype("<i2").tobytes())
self._proc.stdin.flush()
def drain(self) -> Iterator[bytes]:
"""Yields whatever ffmpeg has already emitted, without waiting for more."""
while True:
try:
block = self._blocks.get_nowait()
except queue.Empty:
return
if block is None:
return
yield block
def finish(self) -> Iterator[bytes]:
assert self._proc.stdin is not None
self._proc.stdin.close()
self._reader.join(timeout=120)
code = self._proc.wait(timeout=30)
error = self._proc.stderr.read().decode("utf-8", "replace").strip()[-400:]
if code != 0:
raise RuntimeError(error or f"ffmpeg exited with {code}")
yield from self.drain()
def abort(self) -> None:
if self._proc.poll() is None:
self._proc.kill()
engine = KokoroRu()
state: dict[str, str | None] = {"status": "loading", "error": None}
def boot() -> None:
try:
engine.load()
state["status"] = "ready"
log.info("ready: voices=%s", ", ".join(engine.available_voices()))
except Exception as exc:
state["status"] = "error"
state["error"] = f"{type(exc).__name__}: {exc}"
log.exception("model failed to load")
@asynccontextmanager
async def lifespan(_app: FastAPI):
# Off the event loop: loading pulls ~700 MB of weights and runs three ONNX
# sessions, and /healthz has to stay answerable while it happens.
threading.Thread(target=boot, name="kokoro-load", daemon=True).start()
yield
app = FastAPI(title="kokoro-ru OpenAI TTS", version="1.0.0", lifespan=lifespan)
Format = Literal["mp3", "opus", "aac", "flac", "wav", "pcm"]
class SpeechRequest(BaseModel):
# `protected_namespaces` silences pydantic's warning about the `model_`
# prefix; `extra="ignore"` absorbs the fields newer OpenAI clients add
# (instructions, the legacy `format` alias) without failing the request.
model_config = ConfigDict(extra="ignore", protected_namespaces=())
input: str = Field(min_length=1)
model: str = MODEL_ID
voice: str | None = None
response_format: Format = "wav"
speed: float | None = Field(default=None, ge=0.25, le=4.0)
class StreamSpeechRequest(SpeechRequest):
# Streaming needs a container that tolerates unknown length up front, so wav
# (whose header declares the final sizes) and the raw formats are out. mp3
# and opus emit bytes as they go, which is the whole point of the endpoint.
response_format: Literal["mp3", "opus"] = "mp3"
def fail(status: int, message: str, param: str | None = None, code: str | None = None) -> JSONResponse:
return JSONResponse(
status_code=status,
content={
"error": {
"message": message,
"type": "invalid_request_error" if status < 500 else "server_error",
"param": param,
"code": code,
}
},
)
def resolve_voice(requested: str | None) -> str | None:
name = (requested or DEFAULT_VOICE).strip().lower()
name = VOICE_ALIASES.get(name, name)
return name if name in engine.available_voices() else None
# response_model=None: the handler returns a Response subclass directly, and
# FastAPI would otherwise try to build a Pydantic model out of the union.
@app.post("/v1/audio/speech", response_model=None)
def create_speech(request: SpeechRequest) -> Response | JSONResponse:
if state["status"] != "ready":
return fail(503, f"model is not ready: {state['status']}", code="model_not_ready")
voice = resolve_voice(request.voice)
if voice is None:
available = ", ".join(engine.available_voices())
return fail(
400,
f"unknown voice {request.voice!r}; available: {available}",
param="voice",
code="unknown_voice",
)
try:
audio = engine.synthesize(request.input, voice, request.speed or 1.0)
except Exception as exc:
log.exception("synthesis failed")
return fail(500, f"synthesis failed: {exc}", code="synthesis_failed")
if audio.size == 0:
return fail(
400,
"input contains no speakable text for the Russian G2P",
param="input",
code="no_phonemes",
)
try:
payload = encode(audio, request.response_format)
except Exception as exc:
log.exception("encoding to %s failed", request.response_format)
return fail(500, f"encoding to {request.response_format} failed: {exc}", code="encoding_failed")
return Response(
content=payload,
media_type=CONTENT_TYPES[request.response_format],
headers={"model-id": MODEL_ID, "voice-id": voice},
)
# response_model=None for the same reason as create_speech above.
@app.post("/v1/audio/speech/stream", response_model=None)
def stream_speech(request: StreamSpeechRequest) -> Response | JSONResponse:
if state["status"] != "ready":
return fail(503, f"model is not ready: {state['status']}", code="model_not_ready")
voice = resolve_voice(request.voice)
if voice is None:
available = ", ".join(engine.available_voices())
return fail(
400,
f"unknown voice {request.voice!r}; available: {available}",
param="voice",
code="unknown_voice",
)
chunks = engine.iter_audio_chunks(request.input, voice, request.speed or 1.0)
try:
# Pulled before responding: once the status line is sent it cannot become
# a 400, and input with no speakable text has to keep failing that way.
first = next(chunks)
except StopIteration:
return fail(
400,
"input contains no speakable text for the Russian G2P",
param="input",
code="no_phonemes",
)
def body() -> Iterator[bytes]:
encoder = StreamEncoder(request.response_format)
try:
encoder.push(first)
yield from encoder.drain()
for chunk in chunks:
encoder.push(chunk)
yield from encoder.drain()
yield from encoder.finish()
except Exception:
log.exception("streaming synthesis failed")
raise
finally:
chunks.close()
encoder.abort()
return StreamingResponse(
body(),
media_type=CONTENT_TYPES[request.response_format],
headers={"model-id": MODEL_ID, "voice-id": voice},
)
@app.get("/v1/models")
def list_models() -> dict:
return {
"object": "list",
"data": [{"id": MODEL_ID, "object": "model", "created": MODEL_CREATED, "owned_by": "zaakirio"}],
}
@app.get("/v1/voices")
def list_voices() -> dict:
return {
"object": "list",
"ready": state["status"] == "ready",
"data": [
{"id": name, "object": "voice", "checkpoint": VOICE_SPECS[name][0], "gender": VOICE_SPECS[name][1]}
for name in engine.available_voices()
],
}
@app.get("/healthz")
def healthz() -> JSONResponse:
ready = state["status"] == "ready"
return JSONResponse(
status_code=200 if ready else 503,
content={
"status": state["status"],
"model": MODEL_ID,
"voices": engine.available_voices(),
"sample_rate": SAMPLE_RATE,
"error": state["error"],
},
)
@@ -0,0 +1,82 @@
"""Bake every kokoro-ru asset the server needs into the image.
Two things make a plain `FROM python` image useless for this model at runtime,
and both are fixed here at build time:
* kokoro-ru's checkpoints and its recompiled espeak-ng data live in the HF
cache by default, and the HF cache is part of the disposable container
layer, so every `podman run` would re-download ~700 MB.
* ruaccent writes its ONNX models, dictionaries and Koziev data into its own
`site-packages/ruaccent` directory. It only downloads when those files are
missing, so a single `load()` here means the runtime never touches the
network.
RuG2P resolves espeak-data/ and kokoro-config.json relative to ru_g2p.py, so
the snapshot layout has to stay flat inside KOKORO_MODEL_DIR.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from huggingface_hub import snapshot_download
REPO = os.environ.get("KOKORO_RU_REPO", "zaakirio/kokoro-ru")
# A commit, not a branch: "main" would silently change the weights under a
# rebuild that only touched an unrelated line of the Nix module.
REVISION = os.environ.get("KOKORO_RU_REVISION", "main")
DEST = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
VOICES = [v.strip() for v in os.environ.get("KOKORO_RU_VOICES", "sveta,masha,dima").split(",") if v.strip()]
# sveta and masha share one checkpoint and differ only by voicepack, so the two
# female voices cost one 327 MB download, not two.
CHECKPOINTS = {
"sveta": "kokoro-ru-v2-base.pth",
"masha": "kokoro-ru-v2-base.pth",
"dima": "kokoro-ru-v2-dima.pth",
}
PATTERNS = [
# KModel reads config.json; RuG2P reads kokoro-config.json for the phoneme
# vocab. They are not the same file and both are required.
"config.json",
"kokoro-config.json",
"ru_g2p.py",
# Stock espeak-ng ru_dict ignores combining-acute stress marks, which is the
# one thing this whole front-end exists to fix. The model repo ships a
# recompiled dictsource; there is no substitute to fall back to.
"espeak-data/**",
*(CHECKPOINTS[v] for v in VOICES if v in CHECKPOINTS),
*(f"voices/{v}.pt" for v in VOICES),
]
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
DEST.mkdir(parents=True, exist_ok=True)
snapshot_download(
repo_id=REPO,
revision=REVISION,
allow_patterns=PATTERNS,
local_dir=str(DEST),
)
logging.info("kokoro-ru assets in %s at %s", DEST, REVISION)
missing = [name for name in VOICES if not (DEST / "voices" / f"{name}.pt").exists()]
if missing:
raise SystemExit(f"voice packs missing after download: {missing}")
# Warm ruaccent into site-packages so `load()` short-circuits at runtime.
from ruaccent import RUAccent
accent = RUAccent()
accent.load(omograph_model_size="turbo3.1", use_dictionary=True, tiny_mode=False)
logging.info("ruaccent warm: %s", accent.process_all("Здравствуйте, как ваши дела?"))
if __name__ == "__main__":
main()
@@ -0,0 +1,21 @@
# torch is installed separately in the Dockerfile from the CPU-only index;
# do not add it here or pip would pull the ~2.5 GB CUDA build over it.
#
# ru_g2p.py (from zaakirio/kokoro-ru) imports three things stock kokoro does not
# pull on its own: the espeak-ng backend of misaki, ruaccent for stress, and the
# phonemizer fork whose EspeakWrapper misaki drives.
kokoro==0.9.4
misaki[en]>=0.9.4
phonemizer-fork
espeakng-loader
ruaccent
# kokoro's Albert encoder and ruaccent's ONNX exports both go through
# transformers. ru_g2p.py shims token_type_ids for v5, so the floor is what
# matters, not the ceiling.
transformers>=4.46
fastapi
uvicorn
imageio-ffmpeg
numpy>=1.26,<3
+190
View File
@@ -0,0 +1,190 @@
{
config,
lib,
pkgs,
xlib,
...
}:
# Open WebUI — self-hosted AI chat UI, deployed here as a UI-client for
# external LLM APIs (OpenAI-compatible: OpenAI, OpenRouter, vLLM, LM Studio,
# GroqCloud, Mistral, etc.). Runs locally without bundled Ollama.
#
# Architecture mirrors modules/containers/{3x-ui,tape-rotation}.nix:
# - one container, one systemd unit + a root.target
# - data on /mnt/services/nodes/<host>/open-webui/data → /app/backend/data
# (see AGENTS.md §Подтверждённые инварианты #2 — guard chain is satisfied
# because mkServiceStorage already bind-mounts /mnt/services on boot)
# - host port bound to 127.0.0.1 only — the only ingress is the nginx
# vhost open.zeroq.su (no firewall exception, no public exposure).
# Same pattern as 3x-ui.nix:30-31 binding the panel to 127.0.0.1:2049.
#
# Secrets come from a single sops-encrypted dotenv file
# (format = "dotenv", key = "" → whole file). The owner creates the
# encrypted file with `sops modules/containers/secrets/open-webui.env`
# after filling the .example template next to it.
#
# Hard requirement (env.py:762 — SystemExit at startup):
# WEBUI_SECRET_KEY must be set when WEBUI_AUTH=true.
# Generate with: head -c 24 /dev/urandom | base64
#
# Reverse-proxy requirements (docs.openwebui.com/reference/https):
# - WEBUI_URL = public HTTPS URL (OAuth callbacks, internal links)
# - CORS_ALLOW_ORIGIN = same public URL (else WebSocket fails silently)
# - proxy_buffering off (else SSE streaming breaks markdown)
# - proxy_read_timeout ≥ 300s (LLM responses can run minutes)
# - WebSocket pass-through (Upgrade / Connection headers)
# All of the above are wired into modules/server/nginx.nix:open.zeroq.su.
let
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/open-webui";
in
{
virtualisation = {
podman = {
enable = true;
autoPrune = {
enable = true;
flags = [ "--all" ];
};
dockerCompat = true;
};
oci-containers = {
backend = "podman";
containers."open-webui" = {
image = "ghcr.io/open-webui/open-webui:main";
environment = {
TZ = "Europe/Moscow";
# Container-internal port (also the upstream default).
PORT = "8080";
# Required when behind a public HTTPS URL — OAuth callbacks,
# share links and internal redirects resolve against this.
WEBUI_URL = "https://open.zeroq.su";
# Must exactly match WEBUI_URL or WebSocket connections fail
# silently (per upstream HTTPS docs). nginx (127.0.0.1) is the
# only allowed origin, so a single explicit URL is enough.
CORS_ALLOW_ORIGIN = "https://open.zeroq.su";
# Honour X-Forwarded-* headers from the reverse proxy.
FORWARDED_ALLOW_IPS = "127.0.0.1";
# Closed self-hosted: admin creates accounts manually after the
# first boot via WEBUI_ADMIN_* from the sops env file.
WEBUI_AUTH = "True";
ENABLE_SIGNUP = "False";
ENABLE_LOGIN_FORM = "True";
ENABLE_VERSION_UPDATE_CHECK = "False";
# Out of the box Open WebUI phones home to Scarf. The opt-outs
# below preserve the previous behaviour from the stub at
# modules/server/open-webui.nix (still in tree, commented out in
# modules/server/default.nix:41) until that file is removed.
ANONYMIZED_TELEMETRY = "False";
DO_NOT_TRACK = "True";
SCARF_NO_ANALYTICS = "True";
# No bundled providers. Owners wire OPENAI_API_KEY /
# OPENAI_API_BASE_URL / etc. either via the sops env file
# (see sops.secrets."open-webui-env" below) or interactively in
# Admin → Settings → Connections once WEBUI_AUTH=true. Empty
# base URL is intentional: an empty OPENAI_API_BASE_URL
# disables the default /ollama proxy and prevents the container
# from probing localhost:11434 on boot.
OLLAMA_BASE_URL = "";
OPENAI_API_BASE_URL = "";
};
# Mount the decrypted dotenv only when the sops file exists. Until
# the owner creates ./secrets/open-webui.env, the inline environment
# is the only source — and the container will refuse to start with
# WEBUI_SECRET_KEY="" (env.py:762 — SystemExit). The error message is
# the clear signal that the secret needs to be created.
# Path comes from the sops block below (`config.sops.secrets.<attr>.path`)
# rather than a hardcoded "/run/secrets/<attr>" — see invariant S1
# in docs/arch/invariants.md. Guards the sopsFile existence so the
# block is optional; the same predicate is what `sops.secrets` uses
# to decide whether to declare the attr at all, so `.path` is only
# read when the secret actually exists.
environmentFiles =
lib.optional (builtins.pathExists ./secrets/open-webui.env)
config.sops.secrets."open-webui-env".path;
volumes = [
"${panel}/data:/app/backend/data:rw"
];
log-driver = "journald";
# 127.0.0.1 only — the container is not exposed externally.
ports = [ "127.0.0.1:8080:8080/tcp" ];
};
};
};
# Enable container name DNS for all Podman networks (mirrors 3x-ui.nix:120-128).
networking.firewall.interfaces =
let
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
in
{
"${matchAll}".allowedUDPPorts = [ 53 ];
};
systemd = {
services = {
"podman-open-webui" = {
serviceConfig.Restart = lib.mkOverride 90 "always";
partOf = [ "podman-compose-open-webui-root.target" ];
wantedBy = [ "podman-compose-open-webui-root.target" ];
};
"podman-update-open-webui" = {
path = [ pkgs.podman ];
serviceConfig = {
Type = "oneshot";
TimeoutSec = 300;
};
script = ''
podman pull ghcr.io/open-webui/open-webui:main
systemctl restart podman-open-webui.service
'';
};
};
# Starts/stops together with the open-webui container.
targets."podman-compose-open-webui-root" = {
unitConfig.Description = "Root target for open-webui.";
wantedBy = [ "multi-user.target" ];
};
# Enable automatic image updates:
# systemd.timers."podman-update-open-webui" = {
# wantedBy = [ "timers.target" ];
# timerConfig = {
# OnCalendar = "weekly";
# Persistent = true;
# };
# };
tmpfiles.rules = [
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
"root"
"root"
)
(xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${panel}/data" "0755" "root" "root")
# Relabel panel dir for SELinux so containers can access it.
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
];
};
# sops secret is declared only when the encrypted file actually exists,
# so the flake still evaluates (and rebuilds apply) on a host that hasn't
# created the secret yet. Once ./secrets/open-webui.env is created and
# encrypted with `sops modules/containers/secrets/open-webui.env`, this
# condition becomes true and the secret is wired in.
#
# Hard requirement (env.py:762 — SystemExit at startup):
# WEBUI_SECRET_KEY must be present in the env file when WEBUI_AUTH=true.
sops.secrets = lib.optionalAttrs (builtins.pathExists ./secrets/open-webui.env) {
"open-webui-env" = {
# key = "" → decrypt the whole file, not a single key.
# format = "dotenv" → the file IS one .env ready for environmentFiles:
# every non-comment KEY=VALUE line lands in the container environment.
# After this module is wired the file is mounted at
# /run/secrets/open-webui-env (sops-nix default for this attr name).
key = "";
format = "dotenv";
sopsFile = ./secrets/open-webui.env;
mode = "0400";
};
};
}
+12 -3
View File
@@ -6,6 +6,15 @@
xlib, xlib,
... ...
}: }:
let
# Composite env file path shared by the generator
# (remnawave-env.service below) and the container's `environmentFiles`.
# Lifting to a single binding prevents the two copies from drifting
# apart in future edits — see invariant S1 in docs/arch/invariants.md.
# Note: this is NOT a sops materialization (it's written by a oneshot),
# so `config.sops.secrets.<...>.path` is not the right primitive here.
envFile = "/run/secrets/remnawave-env";
in
{ {
# Runtime # Runtime
virtualisation.podman = { virtualisation.podman = {
@@ -58,7 +67,7 @@
# "WEBHOOK_URL" = "https://your-webhook-url.com/endpoint"; # "WEBHOOK_URL" = "https://your-webhook-url.com/endpoint";
}; };
environmentFiles = [ environmentFiles = [
"/run/secrets/remnawave-env" envFile
]; ];
ports = [ ports = [
"3003:3003/tcp" "3003:3003/tcp"
@@ -126,14 +135,14 @@
User = "root"; User = "root";
}; };
script = '' script = ''
cat > /run/secrets/remnawave-env <<EOF cat > ${envFile} <<EOF
DATABASE_URL=$(cat ${config.sops.secrets.DATABASE_URL.path}) DATABASE_URL=$(cat ${config.sops.secrets.DATABASE_URL.path})
DATABASE_PASSWORD=$(cat ${config.sops.secrets.DATABASE_PASSWORD.path}) DATABASE_PASSWORD=$(cat ${config.sops.secrets.DATABASE_PASSWORD.path})
JWT_AUTH_SECRET=$(cat ${config.sops.secrets.JWT_AUTH_SECRET.path}) JWT_AUTH_SECRET=$(cat ${config.sops.secrets.JWT_AUTH_SECRET.path})
JWT_API_TOKENS_SECRET=$(cat ${config.sops.secrets.JWT_API_TOKENS_SECRET.path}) JWT_API_TOKENS_SECRET=$(cat ${config.sops.secrets.JWT_API_TOKENS_SECRET.path})
WEBHOOK_SECRET_HEADER=$(cat ${config.sops.secrets.WEBHOOK_SECRET_HEADER.path}) WEBHOOK_SECRET_HEADER=$(cat ${config.sops.secrets.WEBHOOK_SECRET_HEADER.path})
EOF EOF
chmod 600 /run/secrets/remnawave-env chmod 600 ${envFile}
''; '';
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
}; };
+10
View File
@@ -0,0 +1,10 @@
WEBUI_SECRET_KEY=ENC[AES256_GCM,data:l6USiQmMkMz/zniIebT35HfXxZI8qrhe6Cdl8hpT98c=,iv:Po9bova4dfiykl+ckH4v6DqzSJOgULx7ro3kXMFRvFI=,tag:7jurD14N7QDRHX5ruFDEeQ==,type:str]
WEBUI_ADMIN_EMAIL=ENC[AES256_GCM,data:EZgNXSpbpROz3TZRLaSQTQ==,iv:i98kChemam9nB3iCMwCTRYB69b2eUBy6QCoeZ3AjAP0=,tag:INnB1Zp9VA6M//yyAhRh3A==,type:str]
WEBUI_ADMIN_NAME=ENC[AES256_GCM,data:8l8dJ85p,iv:LltveatNlX4FEGmxhtYLYmviIvLK0xSMuVsk9DRRglw=,tag:OrTlnOLlQe03hoYTkaPotg==,type:str]
WEBUI_ADMIN_PASSWORD=ENC[AES256_GCM,data:PKfZQHiAa96vcGUCGigjXQ==,iv:WLb1mgCV3IJHnHcBvf4yAPiautgXqCC2L2bzt6i0t7U=,tag:nw78tvyUOYmGMunBwvIr+A==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4d3pNVlZEQS85d2R3WUZX\nKy9iOFZ4MjU2UkQwVHdobTlBY3l0MldONWlvCnU5dllobmtLQXlMM28xN0FSTmxD\nNnhmZVRwdnpaZ3NkZDVCWERBckZiQjgKLS0tIFBPeXZMNXRjZ3pBQUlndXB5MTBB\nMVdhSGJvZkE2VzZiZ2VxL0RKTDJ2aDQKsxlibeAoO74411VemXT+8UBG0JdemgHD\nVONIEp/VsbEJDWgDfSGhLaH4KN2hTsCtyhdkCU0FohgWB+xWyJz6MA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
sops_lastmodified=2026-10-07T09:32:44Z
sops_mac=ENC[AES256_GCM,data:SSHgEEc3u2Zf13q5W4LD7bkrVlQTzLIYiZWXhBiDS6CjC4fUZcJq99OTSTNixzqpxSdnjeRtmzA6d6vGNfxvEOmsE1f4hBNm9ps0RHU4yLfr5vQG9Ff973uDwDU+JMqP3aMU+xpUuPkhW0zRpeST+w0thuPtjzhR2z/A2yPvYzU=,iv:5/cfD51eK0R9cGsr4wZu6CnwEdMjP0CYj3CM7+X4XQg=,tag:1FRcAULHG+XzmRiTMK8aWQ==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.13.3
@@ -0,0 +1,17 @@
JWT_SECRET=ENC[AES256_GCM,data:+ut+3v4KrckbDT5m85JhQd8x2ayF55Uy5FiEw8qTJoBgz7Zz+HprhxPB09RDd6CX11SrcsDcf6vW3wOE7IdeQA==,iv:c3GqspoQH2+2NQvsqip3bs4XW1PWSZtK+l7HzE83Qj8=,tag:hDqFgPa8gYLqPeA0svGWfw==,type:str]
ADMIN_PASSWORD=ENC[AES256_GCM,data:UxcSEO7opTme9DR4XM3/FQ==,iv:nSpFt7rVp0K+hAc3aAoorw5XDMNK0V+zeBw4GHwTsOs=,tag:fQ1u/WtlVfEhc7fZnLnboQ==,type:str]
APP_URL=ENC[AES256_GCM,data:OJAv0C1DHYbFltgXmcSG/s97Lf3qJovkcEsPA9Xr,iv:Fw9Mh/+dYgah+/OWPBtRRXkO42KXWvKIuylyXfcaRK8=,tag:a6A8xS9aRyjvEfZvSxgMuQ==,type:str]
CORS_ORIGINS=ENC[AES256_GCM,data:z4MvIbQcvk+aUaME/llV7rWaDtCFYIT0nVGtlD8j,iv:oAL5NcWOfez+vVbKoibUIOagePROKW+4QV81sK+Cets=,tag:+QftIwvmTADEFMEz+ZCh4A==,type:str]
SMTP_HOST=
SMTP_PORT=ENC[AES256_GCM,data:QnI=,iv:PQwsVoOnLmTrnpUTaQAEOX3VG2hTLm/qnZjwIOL9kac=,tag:SZxCnlr0qTxH65bZ53rvQQ==,type:str]
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=ENC[AES256_GCM,data:TaHhXO7WVUzywpUxTr5l+IG7QfXY,iv:gLqOSZBBzC9v/BT+r+ENLjApTmLsra2jDbenJLHn4AY=,tag:HCmGtfnVIjDktQpTtUbc9A==,type:str]
NOTIFY_EMAIL=
TAPE_LABEL_REGEX=
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnb283UjRSRU1SYjBxZWlz\nOWw2cXM2TTU2QmdWUG5nUU9vWVZhUDZoelJRCi9McmV0Q05hNVpXSllsbUYwdkEw\nTnU3OWRCcFhrQzg4blhuRFJjdDVkUFkKLS0tIEt4Nzc2ZWtOL1VQQzVObzZWYURu\nWFUwVWp5OUhDME0xVlBRS3psdVBSd0EKsy77QR7CveXQdKlo+JeNSaNpnUh//AoP\nV+hbUSIj05Ws20rr9uk8uTDnjnc91r2vxGWxznXf6M9putZfARBdfQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
sops_lastmodified=2026-09-24T13:11:03Z
sops_mac=ENC[AES256_GCM,data:xJO2u9jQM8XiVYekVvwN+iv3megGpf80F1ANib9Kro/kgvTQUZU14jmku8OjfRtjrFsM9b/cBr+ml0Z+MSknmwtR4D3mfRIa0yFfqmS/VZJs8SrH2+c/a8kYGhDNcWgAbx+u/tZB8q0QrQsbDYmN8yvsNwWi2ixMLKlv2thPIbA=,iv:CEgU5499Gr0gD+M5iSYJ315r7RU9RWKKapXywVCQivo=,tag:9YTO7K/zsINSOXfR6PaG8A==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.13.3
+190
View File
@@ -0,0 +1,190 @@
{
config,
lib,
pkgs,
xlib,
...
}:
# TapeRotation — web app for tracking and rotation of backup tape
# cartridges. https://github.com/ElizarovEugene/TapeRotation
#
# Podman adaptation of the upstream docker-compose deployment. Two
# containers on a shared "taperotation_default" network (mirrors the
# compose project network):
# - taperotation-backend: FastAPI/uvicorn on :8001, SQLite at /data,
# file attachments at /app/uploads
# - taperotation-frontend: nginx serving the built React app on :80,
# proxying /api to http://backend:8001
# The backend container gets a static IP on the shared network and the
# frontend maps "backend" → that IP via --add-host, because this host's
# CoreDNS service owns port 53 on every interface: the podman network DNS
# plugin (aardvark-dns) cannot bind on the network gateway, so a network
# with dns_enabled would refuse to attach containers.
#
# Published host port 5174 → container:80 for the web UI. Keep it out
# of networking.firewall like the other panel ports and front it with an
# nginx vhost, e.g. in server/nginx.nix:
# { domain = "tape-rotation.zeroq.su"; port = 5174; }
# and set APP_URL / CORS_ORIGINS in the sops-encrypted env file.
#
# Instance config lives in one sops-encrypted .env file (mirrors the
# upstream .env.example, sops-nix format = "dotenv", key = "" → whole
# file): sops modules/containers/secrets/tape-rotation.env
# On first boot the admin account is created from ADMIN_USERNAME /
# ADMIN_PASSWORD from that file.
let
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/tape-rotation";
in
{
virtualisation = {
podman = {
enable = true;
autoPrune = {
enable = true;
flags = [ "--all" ];
};
dockerCompat = true;
};
oci-containers = {
backend = "podman";
containers = {
"taperotation-backend" = {
image = "docker.io/elizaroveugene/taperotation-backend:latest";
environment = {
"DATABASE_URL" = "sqlite:////data/taperotation.db";
"JWT_EXPIRE_MINUTES" = "480";
"ADMIN_USERNAME" = "admin";
"ADMIN_LANGUAGE" = "en";
"NOTIFY_DAYS_BEFORE" = "7";
"TZ" = "Europe/Moscow";
};
# Path resolved from the sops block at the bottom of this file —
# see invariant S1 in docs/arch/invariants.md.
environmentFiles = [ config.sops.secrets."tape-rotation-env".path ];
volumes = [
"${panel}/db:/data:rw"
"${panel}/uploads:/app/uploads:rw"
];
log-driver = "journald";
extraOptions = [
"--network=taperotation_default"
# Static IP the frontend reaches "backend" at (see --add-host
# in the frontend container; network DNS is disabled).
"--ip=10.89.0.10"
];
};
"taperotation-frontend" = {
image = "docker.io/elizaroveugene/taperotation-frontend:latest";
ports = [
"0.0.0.0:5174:80/tcp"
];
log-driver = "journald";
extraOptions = [
"--network=taperotation_default"
# Baked-in nginx upstream is http://backend:8001; resolve it via
# /etc/hosts since the network has no DNS plugin.
"--add-host=backend:10.89.0.10"
];
};
};
};
};
# Enable container name DNS for all Podman networks.
networking.firewall.interfaces =
let
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
in
{
"${matchAll}".allowedUDPPorts = [ 53 ];
};
systemd = {
services = {
"podman-taperotation-backend" = {
serviceConfig.Restart = lib.mkOverride 90 "always";
after = [ "podman-network-taperotation_default.service" ];
requires = [ "podman-network-taperotation_default.service" ];
partOf = [ "podman-compose-tape-rotation-root.target" ];
wantedBy = [ "podman-compose-tape-rotation-root.target" ];
};
"podman-taperotation-frontend" = {
serviceConfig.Restart = lib.mkOverride 90 "always";
after = [
"podman-network-taperotation_default.service"
"podman-taperotation-backend.service"
];
requires = [ "podman-network-taperotation_default.service" ];
partOf = [ "podman-compose-tape-rotation-root.target" ];
wantedBy = [ "podman-compose-tape-rotation-root.target" ];
};
"podman-network-taperotation_default" = {
path = [ pkgs.podman ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStop = "podman network rm -f taperotation_default";
};
script = ''
# Always (re)create the stack network: the host's CoreDNS owns :53
# on every interface, so the network DNS plugin (aardvark-dns)
# can't bind on the gateway → --disable-dns. --subnet backs the
# backend's static IP. Recreate-on-start also self-heals after a
# `podman system prune` removed the (temporarily unused) network.
podman network rm -f taperotation_default >/dev/null 2>&1 || true
podman network create --disable-dns --subnet=10.89.0.0/24 taperotation_default
'';
partOf = [ "podman-compose-tape-rotation-root.target" ];
wantedBy = [ "podman-compose-tape-rotation-root.target" ];
};
"podman-update-taperotation" = {
path = [ pkgs.podman ];
serviceConfig = {
Type = "oneshot";
TimeoutSec = 300;
};
script = ''
podman pull docker.io/elizaroveugene/taperotation-backend:latest
podman pull docker.io/elizaroveugene/taperotation-frontend:latest
systemctl restart podman-taperotation-backend.service podman-taperotation-frontend.service
'';
};
};
# Starts/stops together with all TapeRotation containers.
targets."podman-compose-tape-rotation-root" = {
unitConfig.Description = "Root target generated by compose2nix.";
wantedBy = [ "multi-user.target" ];
};
# Enable automatic image updates:
# systemd.timers."podman-update-taperotation" = {
# wantedBy = [ "timers.target" ];
# timerConfig = {
# OnCalendar = "weekly";
# Persistent = true;
# };
# };
tmpfiles.rules = [
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
"root"
"root"
)
(xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${panel}/db" "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${panel}/uploads" "0755" "root" "root")
# Relabel panel dir for SELinux so containers can access it.
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
];
};
sops.secrets."tape-rotation-env" = {
# key = "" → decrypt the whole file, not a single key.
# format = "dotenv" → the file IS one .env ready for environmentFiles:
# every non-comment KEY=VALUE line lands in the container environment.
key = "";
format = "dotenv";
sopsFile = ./secrets/tape-rotation.env;
mode = "0400";
};
}
+10 -23
View File
@@ -3,24 +3,15 @@ let
# NixOS-only modules. termux runs nix-on-droid (its own module system, # NixOS-only modules. termux runs nix-on-droid (its own module system,
# class = "nixOnDroid"): options like services.*, users.*, sops.*, disko.* # class = "nixOnDroid"): options like services.*, users.*, sops.*, disko.*
# and nixpkgs.overlays (flake assertion) do not exist there. # and nixpkgs.overlays (flake assertion) do not exist there.
moduleArgs = config: { #
inherit inputs; # `xlib` arrives as a module argument (see lib/mkSystem.nix) and is plain
xlib = config.xlib; # data, not a module option, so nothing here has to declare or set it.
};
defaultModule = defaultModule =
{ {
config,
deviceType,
lib, lib,
xlib, xlib,
... ...
}: }:
let
isDesktop = builtins.elem deviceType [
"primary"
"secondary"
];
in
{ {
imports = imports =
with inputs; with inputs;
@@ -33,36 +24,32 @@ let
# nix-index-database.nixosModules.nix-index # nix-index module # nix-index-database.nixosModules.nix-index # nix-index module
grub2-themes.nixosModules.default # grub2 themes module grub2-themes.nixosModules.default # grub2 themes module
sops-nix.nixosModules.sops # sops module sops-nix.nixosModules.sops # sops module
justray.nixosModules.default
self.homeConfigurations.default.nixosModule # default homeConfigurations self.homeConfigurations.default.nixosModule # default homeConfigurations
disko.nixosModules.disko # disko module disko.nixosModules.disko # disko module
] ]
++ lib.optional isDesktop ../desktop # desktop class: primary/secondary # desktop class: primary/secondary
++ lib.optional xlib.isDesktop ./desktop
# device-type module dir; "minimal" has no extra modules # device-type module dir; "minimal" has no extra modules
++ lib.optional (!isDesktop && deviceType != "minimal") (./. + "/${deviceType}"); ++ lib.optional (!xlib.isDesktop && xlib.device.type != "minimal") (./. + "/${xlib.device.type}");
nixpkgs.overlays = with inputs; [ nixpkgs.overlays = with inputs; [
self.nixosOverlays.default self.nixosOverlays.default
]; ];
networking.hostName = lib.mkDefault config.xlib.device.hostname; networking.hostName = lib.mkDefault xlib.device.hostname;
_module.args = moduleArgs config;
}; };
strictModule = strictModule =
{ {
config,
deviceType,
lib,
xlib, xlib,
... ...
}: }:
{ {
imports = with inputs; [ imports = [
# ./essentials # ./essentials
# ./users.nix # ./users.nix
./options.nix ./options.nix
(./. + "/${deviceType}") (./. + "/${xlib.device.type}")
# sops-nix.nixosModules.sops # sops-nix.nixosModules.sops
]; ];
_module.args = moduleArgs config;
}; };
in in
{ {
+1 -1
View File
@@ -38,7 +38,7 @@
syncthing = { syncthing = {
enable = true; enable = true;
systemService = true; systemService = true;
configDir = "${xlib.dirs.user-storage}/Syncthing/${config.system.name}"; configDir = "${xlib.dirs.user-storage}/persist/Syncthing/${config.system.name}";
dataDir = "${xlib.dirs.user-home}"; dataDir = "${xlib.dirs.user-home}";
group = "users"; group = "users";
user = "${xlib.device.username}"; user = "${xlib.device.username}";
+8 -1
View File
@@ -2,6 +2,7 @@
config, config,
pkgs, pkgs,
inputs, inputs,
xlib,
... ...
}: }:
{ {
@@ -100,6 +101,9 @@
# Test # Test
rgx rgx
net-tools net-tools
usbtree
iperf3
glow
# lazydocker # lazydocker
# dtop # dtop
# framework-tool-tui # framework-tool-tui
@@ -108,6 +112,9 @@
environment.variables.EDITOR = "fresh"; environment.variables.EDITOR = "fresh";
programs = { programs = {
# nix-ld.enable = true; # nix-ld.enable = true;
justray = {
enable = true;
};
nano = { nano = {
enable = true; enable = true;
nanorc = '' nanorc = ''
@@ -180,7 +187,7 @@
enable = true; enable = true;
config = { config = {
user = { user = {
name = "oqyude"; name = xlib.device.username;
email = "oqyude@gmail.com"; email = "oqyude@gmail.com";
}; };
pull = { pull = {
+6 -4
View File
@@ -8,8 +8,10 @@
# All real hosts (not the bare "minimal" test config) get OOM protection # All real hosts (not the bare "minimal" test config) get OOM protection
# and a bounded journal. # and a bounded journal.
services.earlyoom.enable = lib.mkIf (xlib.device.type != "minimal") true; services = {
services.journald.extraConfig = lib.mkIf (xlib.device.type != "minimal") '' earlyoom.enable = lib.mkIf (xlib.device.type != "minimal") true;
SystemMaxUse=512M journald.settings.Journal = lib.mkIf (xlib.device.type != "minimal") {
''; SystemMaxUse = "512M";
};
};
} }
+27 -3
View File
@@ -1,6 +1,7 @@
{ {
config, config,
pkgs, pkgs,
xlib,
... ...
}: }:
{ {
@@ -20,7 +21,7 @@
}; };
shellInit = '' shellInit = ''
beet-p() { beet-p() {
local base="/home/oqyude/.config/beets/My" local base="${xlib.dirs.user-home}/.config/beets/My"
local rel local rel
rel=$(realpath --relative-to="$base" "$PWD") rel=$(realpath --relative-to="$base" "$PWD")
beet mod "path:$rel" playlist="$*" beet mod "path:$rel" playlist="$*"
@@ -29,7 +30,7 @@
beet im ./ -S $* beet im ./ -S $*
} }
beet-path() { beet-path() {
realpath --relative-to="/home/oqyude/.config/beets/My" "$1" realpath --relative-to="${xlib.dirs.user-home}/.config/beets/My" "$1"
} }
''; '';
shellAliases = { shellAliases = {
@@ -45,7 +46,7 @@
gc = "git add . && git commit -m 'dev: автокоммит $(date +'%Y-%m-%d %H:%M:%S')'"; gc = "git add . && git commit -m 'dev: автокоммит $(date +'%Y-%m-%d %H:%M:%S')'";
y = "yazi"; y = "yazi";
nix-shellp = "nix-shell --run $SHELL -p"; nix-shellp = "nix-shell --run $SHELL -p";
beet-path-library = "realpath --relative-to='/home/oqyude/.config/beets/My' ."; beet-path-library = "realpath --relative-to='${xlib.dirs.user-home}/.config/beets/My' .";
z-proxy = "export ALL_PROXY=socks5://localhost:10808"; z-proxy = "export ALL_PROXY=socks5://localhost:10808";
zh-proxy = "export HTTPS_PROXY=http://localhost:10808 && export HTTP_PROXY=http://localhost:10808"; zh-proxy = "export HTTPS_PROXY=http://localhost:10808 && export HTTP_PROXY=http://localhost:10808";
@@ -62,6 +63,29 @@
z-p-1 = "ssh pubray-1"; z-p-1 = "ssh pubray-1";
z-map-local-proxy = "ssh -R 10808:localhost:10808"; z-map-local-proxy = "ssh -R 10808:localhost:10808";
# authelia — Argon2id hash with the exact params configured for the
# authelia daemon (memory=65536, iterations=3, parallelism=4,
# salt-length=16). Defaults already match, but explicit so an upstream
# default change can't silently produce a hash the daemon rejects.
#
# Note: the old `authelia hash-password --argon2id` form is from
# Authelia ≤4.35. Current CLI is `crypto hash generate argon2
# -v argon2id`. The subcommand takes no positional password — it
# prompts with confirmation (omit confirmation with `--no-confirm`)
# or accepts `--password <value>`.
#
# Example usage:
# authelia-hash
# # interactive prompt (twice — confirmation), then hash on stdout
# authelia-hash --no-confirm
# # single prompt, hash on stdout
# authelia-hash --no-confirm --password 'mypassword'
# # non-interactive (for scripts)
#
# Output goes to stdout — append it to users_database.yml by hand:
# authelia-hash --no-confirm >> /mnt/services/authelia/users_database.yml
authelia-hash = "authelia crypto hash generate argon2 -v argon2id -i 3 -m 65536 -p 4 -s 16";
# Somethings # Somethings
reboot-bios = "sudo systemctl reboot --firmware-setup"; reboot-bios = "sudo systemctl reboot --firmware-setup";
+11 -1
View File
@@ -3,7 +3,16 @@
lib, lib,
... ...
}: }:
lib.mkIf config.xlib.ssh.enable { {
options.host.ssh = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the SSH server with the shared config below.";
};
};
config = lib.mkIf config.host.ssh.enable {
services.openssh = { services.openssh = {
enable = true; enable = true;
allowSFTP = true; allowSFTP = true;
@@ -20,4 +29,5 @@ lib.mkIf config.xlib.ssh.enable {
UsePAM = true; UsePAM = true;
}; };
}; };
};
} }
+2 -2
View File
@@ -11,13 +11,13 @@
description = "Prebuild NixOS closure"; description = "Prebuild NixOS closure";
serviceConfig = { serviceConfig = {
CPUQuota = "20%"; CPUQuota = "20%";
User = "oqyude"; User = xlib.device.username;
Group = "users"; Group = "users";
Nice = 10; Nice = 10;
Type = "oneshot"; Type = "oneshot";
WorkingDirectory = "/tmp"; WorkingDirectory = "/tmp";
Environment = [ Environment = [
"HOME=/home/oqyude" "HOME=${xlib.dirs.user-home}"
]; ];
ExecStart = '' ExecStart = ''
${pkgs.nix}/bin/nix build --no-link /etc/nixos#nixosConfigurations.${config.networking.hostName}.config.system.build.toplevel ${pkgs.nix}/bin/nix build --no-link /etc/nixos#nixosConfigurations.${config.networking.hostName}.config.system.build.toplevel
+62 -126
View File
@@ -1,140 +1,76 @@
{ {
config,
lib, lib,
... ...
}: }:
# Cross-module options: declared here, not in the module that reads them.
#
# An option belongs in this file when at least one context *sets* it while
# another module *reads* it — the reader cannot be the only place that knows
# the option exists. `modules/essentials/ssh.nix` does not belong here: it
# declares and reads `host.ssh.enable` itself, within one module.
{ {
options = { # Remote-builder wiring. A coordinator (e.g. sapphira) sets
xlib = { # `host.builder.clients` to register remote build machines;
device = { # a builder host (e.g. the WSL on vetymae) sets `host.builder.enable`
type = lib.mkOption { # to advertise itself. The two halves are intentionally split so a single
type = lib.types.enum [ # declaration in configurations/* is enough to flip each side.
"minimal" options.host.builder = {
"primary"
"secondary"
"server"
"vds"
"wsl"
"termux"
];
default = "minimal";
description = "Type of device for this host.";
};
username = lib.mkOption {
type = lib.types.str;
default = "oqyude";
description = "Username for host.";
};
hostname = lib.mkOption {
type = lib.types.str;
default = "nixos";
description = "Hostname...";
};
};
ssh = {
enable = lib.mkOption { enable = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; default = false;
description = "Enable SSH server with the standard config."; description = ''
Advertise this host as a remote Nix builder and accept builds
from other machines in the flake over SSH.
'';
};
clients = lib.mkOption {
type = lib.types.listOf lib.types.attrs;
default = [ ];
description = ''
List of remote Nix build machines this coordinator should
register via `nix.buildMachines`. Each entry matches the NixOS
option schema (hostName, sshUser, sshKey, systems,
supportedFeatures, ...). Two extra attributes are consumed by
modules/server/builder.nix and stripped before reaching
`nix.buildMachines`:
- `proxyCommand` — generates a per-builder Host block in the
system-wide OpenSSH config (the nix-daemon runs as root and
cannot see the user's ~/.ssh/config).
- `hostKeyAlias` — alias used inside that SSH matchBlock.
A builder reachable on its own (no ProxyCommand needed) omits
both and gets no SSH matchBlock. Empty by default — opt in by
setting this list.
'';
}; };
}; };
dirs = {
user-home = lib.mkOption { options.host."3x-ui" = {
type = lib.types.str; # Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/)
default = "/home/${config.xlib.device.username}"; # gets mounted read-only into the 3x-ui container so the panel
description = "User home directory."; # can terminate TLS itself. Set null if 3x-ui serves plain HTTP
}; # and TLS is terminated by an upstream nginx.
user-storage = lib.mkOption { certDomain = lib.mkOption {
type = lib.types.str; type = lib.types.nullOr lib.types.str;
default = "${config.xlib.dirs.user-home}/Storage"; default = null;
description = "User storage directory."; example = "pubray1.zeroq.su";
}; description = ''
archive-drive = lib.mkOption { Domain whose LE cert should be mounted into the 3x-ui
type = lib.types.str; container at /root/cert/fullchain.pem and key.pem.
default = "/mnt/archive"; '';
description = "Archive drive mount point.";
};
lamet-drive = lib.mkOption {
type = lib.types.str;
default = "/mnt/lamet";
description = "Lamet drive mount point.";
};
mobile-drive = lib.mkOption {
type = lib.types.str;
default = "/mnt/mobile";
description = "Mobile drive mount point.";
};
therima-drive = lib.mkOption {
type = lib.types.str;
default = "/mnt/therima";
description = "Therima drive mount point.";
};
vetymae-drive = lib.mkOption {
type = lib.types.str;
default = "/mnt/vetymae";
description = "Vetymae drive mount point.";
};
soptur-drive = lib.mkOption {
type = lib.types.str;
default = "/mnt/soptur";
description = "Soptur drive mount point.";
};
wsl-home = lib.mkOption {
type = lib.types.str;
default = "/mnt/c/Users/${config.xlib.device.username}";
description = "WSL home directory.";
};
wsl-storage = lib.mkOption {
type = lib.types.str;
default = "${config.xlib.dirs.wsl-home}/Storage";
description = "WSL storage directory.";
};
server-home = lib.mkOption {
type = lib.types.str;
default = "/home/${config.xlib.device.username}/External";
description = "Server home directory.";
};
server-credentials = lib.mkOption {
type = lib.types.str;
default = "${config.xlib.dirs.server-home}/Credentials/server";
description = "Server credentials directory.";
};
storage = lib.mkOption {
type = lib.types.str;
default = "${config.xlib.dirs.server-home}/Storage";
description = "General storage directory.";
};
calibre-library = lib.mkOption {
type = lib.types.str;
default = "${config.xlib.dirs.server-home}/Books-Library";
description = "Calibre library directory.";
};
music-library = lib.mkOption {
type = lib.types.str;
default = "${config.xlib.dirs.user-home}/Music";
description = "Music library directory.";
};
services-folder = lib.mkOption {
type = lib.types.str;
default = "${config.xlib.dirs.server-home}/Services";
description = "All services folder.";
};
services-mnt-folder = lib.mkOption {
type = lib.types.str;
default = "/mnt/services";
description = "All services folder.";
};
services-nodes-folder = lib.mkOption {
type = lib.types.str;
default = "${config.xlib.dirs.services-mnt-folder}/nodes";
description = "All nodes folder.";
};
postgresql-folder = lib.mkOption {
type = lib.types.str;
default = "${config.xlib.dirs.services-mnt-folder}/postgresql";
description = "PostgreSQL service folder.";
};
}; };
# Publish host:15380 → container:443. Only nodes that host an
# Xray REALITY inbound on container:443 need this (so nginx
# stream can forward TLS to Xray via 127.0.0.1:15380 while Xray
# itself sees incoming connections on its configured port 443).
# Set false on nodes that only run the 3x-ui panel.
reality443Forwarding = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
When true, publish host:15380 → container:443 so Xray
inside the container can serve REALITY on its real
configured port 443 (nginx stream forwards 443 → 15380).
'';
}; };
}; };
} }
+2 -7
View File
@@ -68,14 +68,9 @@ in
}; };
}; };
systemd.mounts = [ systemd.mounts = [
{ (xlib.helpers.mkSystemdBind {
enable = true;
options = "bind,x-systemd.automount,nofail";
requires = [ "local-fs.target" ];
type = "none";
wantedBy = [ "multi-user.target" ];
what = "/home/${xlib.device.username}/Music"; what = "/home/${xlib.device.username}/Music";
where = "/home/${xlib.device.username}/.config/beets"; where = "/home/${xlib.device.username}/.config/beets";
} })
]; ];
} }
+225
View File
@@ -0,0 +1,225 @@
{
config,
lib,
pkgs,
xlib,
...
}:
# Authelia — SSO reverse-proxy (single-factor password login) for protected
# vhosts. Uses nixpkgs' services.authelia module (a native systemd unit with
# hard sandboxing) instead of a podman container — Authelia is a Go binary,
# not a foreign distro, so a container adds nothing but surface area.
#
# Wiring:
# - The internal API listens on 127.0.0.1:9091 only (overrides the nixpkgs
# default `tcp://:9091/` which would bind all interfaces).
# - Two secrets (jwt, storage encryption key) come from
# modules/server/secrets/authelia.yaml via sops-nix, materialised at
# /run/secrets/<name> by the time authelia.service starts.
# - nginx is the only ingress: authelia.zeroq.su vhosts the login UI and
# every protected vhost (currently vtimeline.zeroq.su) does
# `auth_request /authelia` against 127.0.0.1:9091 (see nginx.nix).
# - users_database lives at ${xlib.dirs.authelia-folder}/users_database.yml
# under /mnt/services/authelia/ — outside of sops, intentionally, so it
# can be edited at runtime without `nixos-rebuild` (authelia lazy-reads
# the file on each authentication attempt). The directory + file are
# pre-created by systemd.tmpfiles below with `authelia:authelia` 0750/0400.
#
# Version: pinned transitively via flake inputs.nixpkgs → pkgs.authelia.
# `nix flake update` will roll it forward; no overlay needed. Package is
# also exposed via `environment.systemPackages` so the `authelia` CLI is on
# PATH for the `authelia-hash` shell alias (Argon2id password hashing).
#
# Secrets layout in modules/server/secrets/authelia.yaml (sops-encrypted):
# jwt_secret -> /run/secrets/authelia-jwt-secret
# storage_encryption_key -> /run/secrets/authelia-storage-encryption-key
#
# The users database is NOT in this file — it lives at
# ${xlib.dirs.authelia-folder}/users_database.yml (see the wiring block
# above), edited at runtime without `nixos-rebuild`.
#
# Guarded by `builtins.pathExists` so a missing sops file does NOT break
# `nixos-rebuild switch` — the flake evaluates, Authelia stays disabled
# until the secret file is created and encrypted.
let
cfg = config.host.authelia;
sopsReady = builtins.pathExists ./secrets/authelia.yaml;
in
{
options.host.authelia = {
enable = lib.mkEnableOption ''
Authelia SSO reverse-proxy. When enabled, exposes the internal API on
127.0.0.1:9091 (loopback only — nginx is the only ingress). Activating
this option requires modules/server/secrets/authelia.yaml to exist
and decrypt successfully; otherwise the toplevel build fails on a
missing sopsFile.
'';
cookieDomain = lib.mkOption {
type = lib.types.str;
default = "zeroq.su";
description = ''
Domain scope for Authelia session cookies and the login-UI vhost
(`authelia.<cookieDomain>`). All protected vhosts must be subdomains
of this value for the session cookie to flow through nginx's
auth_request handshake.
'';
};
autheliaFqdn = lib.mkOption {
type = lib.types.str;
default = "authelia.${cfg.cookieDomain}";
description = ''
Public FQDN where the Authelia login UI is served by nginx. Set this
to override the default (authelia.<cookieDomain>) when a CNAME or a
different deployment shape requires it.
'';
};
};
config = lib.mkIf cfg.enable {
# Authelia CLI binary on PATH so the `authelia-hash` shell alias works
# without `nix-shell`. Same `pkgs.authelia` as the daemon's `package`
# below — nothing is rebuilt, just exposed.
environment.systemPackages = [ pkgs.authelia ];
# Pre-create the runtime users_database location at /mnt/services/authelia/.
# Authelia is enabled only on `server`-typed hosts (see server/default.nix)
# where /mnt/services is the durable mountpoint from `mkServiceStorage`.
# The "d" rule creates the directory; the "f" rule seeds an empty file
# if absent so authelia.service starts cleanly (and rejects every login
# until the file is populated — desired safe default).
systemd.tmpfiles.rules = [
"d ${xlib.dirs.authelia-folder} 0750 authelia authelia - -"
"f ${xlib.dirs.authelia-folder}/users_database.yml 0400 authelia authelia - -"
];
services.authelia.instances."" = {
enable = true;
# Default is already pkgs.authelia; pinned here for clarity and to
# give an obvious handle for future overrides (e.g. an overlay to
# hold a specific upstream version during CVE windows).
package = pkgs.authelia;
secrets = lib.mkIf sopsReady {
# Read paths through `config.sops.secrets.<attr>.path` (not via a
# hardcoded "/run/secrets/<attr>") so that any future `path =`
# override on the sops block below is picked up automatically —
# see invariant S1 in docs/arch/invariants.md.
jwtSecretFile = config.sops.secrets."authelia-jwt-secret".path;
storageEncryptionKeyFile = config.sops.secrets."authelia-storage-encryption-key".path;
};
settings = {
# Override the nixpkgs default (`tcp://:9091/`) — binding all
# interfaces would expose the API to the LAN. nginx is the only
# allowed ingress, talking to 127.0.0.1:9091.
server.address = "tcp://127.0.0.1:9091";
log = {
level = "info";
format = "text";
};
authentication_backend.file = {
# Live users database under /mnt/services/authelia/users_database.yml.
# Authelia does NOT validate-config this path — it only opens it
# when verifying a user password (lazy read on every login attempt),
# so the file can be edited at runtime without restarting the
# service. Permissions/owner are set by the systemd.tmpfiles rule
# above; sops materialisation was removed intentionally so editing
# works without `nixos-rebuild`.
path = "${xlib.dirs.authelia-folder}/users_database.yml";
password = {
algorithm = "argon2id";
iterations = 3;
salt_length = 16;
parallelism = 4;
memory = 65536;
};
};
storage.local.path = "/var/lib/authelia/db.sqlite3";
session = {
expiration = "1h";
inactivity = "5m";
cookies = [
{
domain = cfg.cookieDomain;
authelia_url = "https://${cfg.autheliaFqdn}/";
# NOTE: Authelia 4.x has no per-cookie `secure` knob;
# `Set-Cookie`'s Secure flag is auto-determined from the
# inbound request's effective scheme at runtime (it does
# trust X-Forwarded-Proto when it sees it). The HTTP
# loopback binding (server.address = 127.0.0.1:9091)
# means this only works because nginx sets
# X-Forwarded-Proto $scheme, both via
# recommendedProxySettings and explicitly on the
# /authelia subrequest in nginx.nix. Don't be tempted
# to re-add `secure: "always"` here — validate-config
# rejects it as an unknown key.
}
];
};
access_control = {
default_policy = "deny";
rules = [
{
# Wildcard against every *.zeroq.su vhost that adds an
# `auth_request /authelia` to its nginx config (currently
# vtimeline). A bare `domain: "*"` is schema-invalid in
# Authelia and silently falls through to default_policy,
# which is why the first attempt landed on 403 with no
# redirect. Adding a new protected vhost under this domain
# requires no change here — the wildcard does the work.
domain = "*.${cfg.cookieDomain}";
policy = "one_factor";
}
];
};
notifier = {
disable_startup_check = true;
filesystem.filename = "/var/lib/authelia/notifier.txt";
};
};
# No `settingsFiles` — the users_database file is read directly
# via `settings.authentication_backend.file.path` above. Adding
# it here would put `users:` under viper's config schema check
# (validate-config), which rejects it as an unknown top-level key.
};
# Wait for sops-nix to materialise the secrets before Authelia starts.
# Without this, authelia can race ahead of sops and read an empty
# /run/secrets on the very first boot after a switch. Existing boots
# (when /run/secrets is already populated) skip the wait instantly.
# `sops-nix.service` is the systemd service that the sops-nix module
# creates to deploy credentials; depending on its name avoids the
# "race between tmpfiles-setup and the sops materialiser" that the
# previous tmpfiles-symlink design implicitly relied on.
systemd.services.authelia.after = [ "sops-nix.service" ];
systemd.services.authelia.wants = [ "sops-nix.service" ];
# sops wiring. Guarded by builtins.pathExists so the flake still
# evaluates when ./secrets/authelia.yaml hasn't been created yet —
# a clean checkout would otherwise fail every nixos-rebuild switch.
# Once the file exists and is encrypted, this condition becomes true
# and the two secrets (jwt + storage encryption key) are wired in.
# `users_database` is not sops-managed — see the comment on
# `settings.authentication_backend.file.path` above for its runtime
# location under ${xlib.dirs.authelia-folder}/.
sops.secrets = lib.optionalAttrs sopsReady {
"authelia-jwt-secret" = {
format = "yaml";
key = "jwt_secret";
sopsFile = ./secrets/authelia.yaml;
owner = "authelia";
group = "authelia";
mode = "0400";
};
"authelia-storage-encryption-key" = {
format = "yaml";
key = "storage_encryption_key";
sopsFile = ./secrets/authelia.yaml;
owner = "authelia";
group = "authelia";
mode = "0400";
};
};
};
}
+131
View File
@@ -0,0 +1,131 @@
# sapphira (and any other server-class coordinator) — register remote
# builders, and make sure the nix daemon (running as root) can resolve the
# SSH host alias with its ProxyCommand chain.
#
# The `host.builder.clients` option itself is declared in
# modules/options.nix (cross-module). The actual builder list is set by the
# configuration (e.g. configurations/server.nix) — this module is generic
# over every entry on the list.
{
config,
lib,
...
}:
let
# Attributes that belong to the SSH matchBlock only — NOT to
# `nix.buildMachines` (that schema has no hostKeyAlias/proxyCommand).
# Strip them before handing the list to nix.buildMachines.
sshOnlyAttrs = [
"hostKeyAlias"
"proxyCommand"
];
forNix = b: removeAttrs b sshOnlyAttrs;
# After NixOS's nix.buildMachines submodule runs, each entry has all
# attributes defaulted (protocol=ssh, systems=[], etc.). Read from that
# processed list so the formatter never trips on a missing field.
processedBuilders = config.nix.buildMachines;
# Serialise one builder to the textual format Nix's daemon expects in
# `nix.conf`'s `builders` line. Mirrors `buildMachinesText` from
# nixos/modules/config/nix-remote-build.nix so the result is identical
# to what NixOS writes to /etc/nix/machines — we just inline it instead
# of relying on `@/etc/nix/machines`, which Nix 2.34 parses but does
# not act on (the daemon's `external-builders` list stays empty and the
# client reports "configure remote builders via 'builders'" forever).
formatBuilder =
b:
let
# Nix 2.34 refuses to dispatch derivations to a builder whose protocol
# is `ssh` (the NixOS default): the daemon leaves `external-builders`
# empty even when the `builders` line is well-formed, and the client
# falls back to local. `ssh-ng` (the new in-band protocol) actually
# opens the dispatcher. Override the NixOS default here.
proto = "ssh-ng://";
user = if b.sshUser != null && b.sshUser != "" then "${b.sshUser}@" else "";
systems =
if b.system != null then
b.system
else if b.systems != [ ] then
lib.concatStringsSep "," b.systems
else
"-";
sshKey = if b.sshKey != null && b.sshKey != "" then b.sshKey else "-";
maxJobs = toString b.maxJobs;
speedFactor = toString b.speedFactor;
allFeats = b.supportedFeatures ++ b.mandatoryFeatures;
supported = if allFeats == [ ] then "-" else lib.concatStringsSep "," allFeats;
mandatory =
if b.mandatoryFeatures == [ ] then "-" else lib.concatStringsSep "," b.mandatoryFeatures;
publicKey = if b.publicHostKey != null then b.publicHostKey else "-";
in
lib.concatStringsSep " " [
"${proto}${user}${b.hostName}"
systems
sshKey
maxJobs
speedFactor
supported
mandatory
publicKey
];
inlineBuilders = lib.concatMapStringsSep "\n" formatBuilder processedBuilders;
# One OpenSSH host block per builder that needs a ProxyCommand.
# Placed in `programs.ssh.extraConfig` so it ends up in
# /etc/ssh/ssh_config (the file OpenSSH consults system-wide, including
# for the nix-daemon running as root).
#
# Only builders with a `proxyCommand` attribute get a block: a builder
# reachable on its own (e.g. otreca on a public IP) needs no help from
# here. The attribute is the literal ProxyCommand string (passed
# verbatim to ssh); the configuration is responsible for matching it
# with the `hostName` field.
hostBlock = b: ''
Host ${b.hostName}
User ${b.sshUser}
HostKeyAlias ${b.hostKeyAlias or b.hostName}
ProxyCommand ${b.proxyCommand}
StrictHostKeyChecking accept-new
ServerAliveInterval 30
ServerAliveCountMax 3
ControlMaster auto
ControlPersist 60
ConnectTimeout 15
'';
blocks = map hostBlock (lib.filter (b: b ? proxyCommand) config.host.builder.clients);
in
{
config = lib.mkIf (config.host.builder.clients != [ ]) {
# Off-by-default in NixOS. Without this, the nix-remote-build module
# sets `nix.settings.builders = null` and the list is dropped from
# /etc/nix/nix.conf entirely, even though `nix.buildMachines` is
# populated. (The build-machine list still lands in /etc/nix/machines
# but nix-daemon reads `builders`, not /etc/nix/machines, when
# distributedBuilds is false.)
nix.distributedBuilds = true;
nix.buildMachines = map forNix config.host.builder.clients;
# Nix 2.34's daemon does not act on `@/etc/nix/machines` (the file
# format NixOS's nix-remote-build writes to): the `builders` config
# key is parsed for display but `external-builders` stays empty and
# the scheduler ignores it. Inlining the same builder text here — in
# the exact format the NixOS module itself uses — actually wires up
# the SSH dispatch. `mkForce` is required because the nix-remote-build
# module sets `builders = null` whenever distributedBuilds is *false*;
# our config flips it to *true*, so the module's mkIf does not fire
# and there is no actual conflict — but pinning it with mkForce makes
# the intent obvious and survives any future change in default
# behaviour.
nix.settings.builders = lib.mkForce inlineBuilders;
# Append per-builder Host blocks to the system-wide OpenSSH client
# config. `programs.ssh.extraConfig` is of type `lines`, merged across
# modules, and prepended (before `Host *`) in /etc/ssh/ssh_config —
# which is exactly the spot where specific Host blocks have to live.
programs.ssh.extraConfig = lib.concatStrings blocks;
# Parallel builds on sapphira itself stay at 2 — that matches the
# physical cores and keeps the coordinator responsive while the WSL
# absorbs the heavy lifting. The essentials/settings.nix already
# leaves max-jobs at the default `auto` (2 here); no override needed.
};
}
+22 -13
View File
@@ -42,21 +42,30 @@ in
# }; # };
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules =
"d ${libraryDir} 0755 calibre-web calibre-web -" xlib.helpers.mkTmpDirs {
"d ${sourceDir} 0755 calibre-web calibre-web -" dir = libraryDir;
"Z ${libraryDir} 0755 calibre-web calibre-web -" mode = "0755";
"Z ${sourceDir} 0755 calibre-web calibre-web -" user = "calibre-web";
group = "calibre-web";
types = [
"d"
"Z"
]; ];
}
fileSystems = { ++ xlib.helpers.mkTmpDirs {
"${targetDir}" = { dir = sourceDir;
device = "${sourceDir}"; mode = "0755";
fsType = "none"; user = "calibre-web";
options = [ group = "calibre-web";
"bind" types = [
"nofail" "d"
"Z"
]; ];
}; };
fileSystems = xlib.helpers.mkBindMount {
what = sourceDir;
where = targetDir;
}; };
} }
+3
View File
@@ -10,6 +10,7 @@
zeroq.su:53 { zeroq.su:53 {
hosts { hosts {
109.248.161.5 x.zeroq.su 109.248.161.5 x.zeroq.su
192.168.1.20 authelia.zeroq.su
192.168.1.20 calibre.zeroq.su 192.168.1.20 calibre.zeroq.su
192.168.1.20 dns.zeroq.su 192.168.1.20 dns.zeroq.su
192.168.1.20 flux.zeroq.su 192.168.1.20 flux.zeroq.su
@@ -20,11 +21,13 @@
192.168.1.20 kuma.zeroq.su 192.168.1.20 kuma.zeroq.su
192.168.1.20 navidrome.zeroq.su 192.168.1.20 navidrome.zeroq.su
192.168.1.20 nextcloud.zeroq.su 192.168.1.20 nextcloud.zeroq.su
192.168.1.20 open.zeroq.su
192.168.1.20 office.zeroq.su 192.168.1.20 office.zeroq.su
192.168.1.20 pdf.zeroq.su 192.168.1.20 pdf.zeroq.su
192.168.1.20 syncthing.zeroq.su 192.168.1.20 syncthing.zeroq.su
192.168.1.20 talk.zeroq.su 192.168.1.20 talk.zeroq.su
192.168.1.20 turn.zeroq.su 192.168.1.20 turn.zeroq.su
192.168.1.20 vtimeline.zeroq.su
fallthrough fallthrough
} }
cache 300 cache 300
+22 -3
View File
@@ -6,9 +6,13 @@
{ {
imports = [ imports = [
../containers/3x-ui.nix ../containers/3x-ui.nix
../containers/open-webui.nix
../containers/tape-rotation.nix
../pkgs/beets.nix ../pkgs/beets.nix
./acme.nix ./acme.nix
./authelia.nix
./bentopdf.nix ./bentopdf.nix
./builder.nix
./calibre-web.nix ./calibre-web.nix
./chrony.nix ./chrony.nix
./coredns.nix ./coredns.nix
@@ -27,15 +31,17 @@
./samba.nix ./samba.nix
./syncthing.nix ./syncthing.nix
./systemd.nix ./systemd.nix
./ttyd.nix
./vtimeline.nix
./uptime-kuma.nix ./uptime-kuma.nix
# ../containers/remnawave.nix # ../containers/remnawave.nix
# ./coturn.nix # ./coturn.nix
# ./mealie.nix # ./mealie.nix
# ./memos.nix # ./memos.nix
# ./minecraft.nix
# ./n8n.nix # ./n8n.nix
# ./netdata.nix # ./netdata.nix
# ./nfs.nix # ./nfs.nix
# ./open-webui.nix
# ./rsync.nix # ./rsync.nix
# ./step-ca.nix # ./step-ca.nix
# ./stirling-pdf.nix # ./stirling-pdf.nix
@@ -43,8 +49,21 @@
# ./trilium.nix # ./trilium.nix
# ./zerotier.nix # ./zerotier.nix
]; ];
# Server's 3x-ui is the controller panel at x.zeroq.su (nginx HTTP
# terminates TLS upstream, no SNI-routing on 443 needed here because
# there are other vhosts on the same port). Cert is still mounted in
# case 3x-ui is later reconfigured to terminate TLS itself (e.g. for
# direct node-API access); nginx doesn't have to use it.
host."3x-ui".certDomain = "x.zeroq.su";
# Authelia SSO — currently protects vtimeline.zeroq.su (replaces the
# previous nginx auth_basic htpasswd). Cookie domain is .zeroq.su so a
# single Authelia session covers every protected vhost under the zone.
host.authelia = {
enable = true;
cookieDomain = "zeroq.su";
};
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /mnt 0755 root root -" (xlib.helpers.mkTmpfile "d" "/mnt" "0755" "root" "root")
"d ${xlib.dirs.services-mnt-folder} 0755 root root -" (xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
]; ];
} }
+6 -4
View File
@@ -22,8 +22,10 @@
}; };
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = xlib.helpers.mkTmpDirs {
"d ${config.services.gitea.stateDir} 0755 gitea gitea -" dir = config.services.gitea.stateDir;
"z ${config.services.gitea.stateDir} 0755 gitea gitea -" mode = "0755";
]; user = "gitea";
group = "gitea";
};
} }
+10 -23
View File
@@ -5,8 +5,11 @@
... ...
}: }:
let let
sourceDir = "${xlib.dirs.services-mnt-folder}/homebox"; storage = xlib.helpers.mkServiceStorage {
targetDir = "/var/lib/homebox"; name = "homebox";
user = "homebox";
group = "homebox";
};
in in
{ {
services.homebox = { services.homebox = {
@@ -14,33 +17,17 @@ in
settings = { settings = {
HBOX_WEB_HOST = "0.0.0.0"; HBOX_WEB_HOST = "0.0.0.0";
HBOX_WEB_PORT = "7745"; HBOX_WEB_PORT = "7745";
HBOX_STORAGE_CONN_STRING = "file://${targetDir}"; HBOX_STORAGE_CONN_STRING = "file://${storage.target}";
HBOX_STORAGE_PREFIX_PATH = "data"; HBOX_STORAGE_PREFIX_PATH = "data";
HBOX_DATABASE_DRIVER = "sqlite3"; HBOX_DATABASE_DRIVER = "sqlite3";
HBOX_DATABASE_SQLITE_PATH = "${targetDir}/data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1"; HBOX_DATABASE_SQLITE_PATH = "${storage.target}/data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1";
HBOX_OPTIONS_ALLOW_REGISTRATION = "true"; HBOX_OPTIONS_ALLOW_REGISTRATION = "true";
HBOX_OPTIONS_GITHUB_RELEASE_CHECK = "false"; HBOX_OPTIONS_GITHUB_RELEASE_CHECK = "false";
HBOX_MODE = "production"; HBOX_MODE = "production";
HOME = "${targetDir}"; HOME = "${storage.target}";
TMPDIR = "${targetDir}/tmp"; TMPDIR = "${storage.target}/tmp";
}; };
}; };
systemd = { systemd = storage.systemd;
tmpfiles.rules = [
"d ${sourceDir} 0755 homebox homebox -"
"z ${sourceDir} 0755 homebox homebox -"
];
mounts = [
{
enable = true;
options = "bind,x-systemd.automount,nofail";
requires = [ "local-fs.target" ];
type = "none";
wantedBy = [ "multi-user.target" ];
what = "${sourceDir}";
where = "${targetDir}";
}
];
};
} }
+1 -1
View File
@@ -20,7 +20,7 @@
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"z ${config.services.immich.mediaLocation} 0755 immich immich -" (xlib.helpers.mkTmpfile "z" config.services.immich.mediaLocation "0755" "immich" "immich")
]; ];
users.users.immich.extraGroups = [ users.users.immich.extraGroups = [
+1 -1
View File
@@ -21,6 +21,6 @@
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"z /mnt/services/memos 0750 memos memos -" (xlib.helpers.mkTmpfile "z" "${xlib.dirs.services-mnt-folder}/memos" "0750" "memos" "memos")
]; ];
} }
+67
View File
@@ -0,0 +1,67 @@
{
config,
inputs,
pkgs,
xlib,
...
}:
let
storage = xlib.helpers.mkServiceStorage {
name = "minecraft";
user = "minecraft";
group = "minecraft";
mode = "770";
};
in
{
imports = [ inputs.nix-minecraft.nixosModules.minecraft-servers ];
nixpkgs.overlays = [ inputs.nix-minecraft.overlay ];
services.minecraft-servers = {
enable = true;
eula = true;
openFirewall = true;
dataDir = "/var/lib/minecraft";
servers = {
vanilla = {
enable = true;
package = pkgs.fabricServers.fabric-26_2.override {
jre_headless = pkgs.jdk25_headless;
};
jvmOpts = "-Xmx2G -Xms1G";
enableReload = true;
serverProperties = {
view-distance = 6;
simulation-distance = 4;
online-mode = false;
difficulty = 3;
gamemode = 1;
max-players = 5;
server-port = 25565;
motd = "ZeroQ сервак майна епта!";
enable-rcon = true;
"rcon.password" = "zeroq";
};
symlinks.mods = pkgs.linkFarmFromDrvs "mods" (
builtins.attrValues {
Lithium = pkgs.fetchurl {
name = "lithium-fabric-0.25.3+mc26.2.jar";
url = "https://cdn.modrinth.com/data/gvQqBUqZ/versions/f7vZ0VWU/lithium-fabric-0.25.3%2Bmc26.2.jar";
hash = "sha256-/d6S4jjoB1+JrX9wHyo9WFSviLqaZ2VxhKRAexBKxWM=";
};
FerriteCore = pkgs.fetchurl {
name = "ferritecore-9.0.0-fabric.jar";
url = "https://cdn.modrinth.com/data/uXXizFIs/versions/d5ddUdiB/ferritecore-9.0.0-fabric.jar";
hash = "sha256-ITlmxy7ZZ6zHOSvrKKhm+6MB/1a5l2wueAHC233mvyI=";
};
Krypton = pkgs.fetchurl {
name = "krypton-0.3.1.jar";
url = "https://cdn.modrinth.com/data/fQEb0iXm/versions/5WeL0Nkz/krypton-0.3.1.jar";
hash = "sha256-XqiQFWGXPSnlHnUUadUtkhAPNIq0YeEYb2cBLpNCDEg=";
};
}
);
};
};
};
systemd = storage.systemd;
}
+6 -19
View File
@@ -7,8 +7,11 @@
... ...
}: }:
let let
sourceDir = "${xlib.dirs.services-mnt-folder}/n8n"; storage = xlib.helpers.mkServiceStorage {
targetDir = "/var/lib/n8n"; name = "n8n";
user = "nobody";
group = "nogroup";
};
in in
{ {
services.n8n = { services.n8n = {
@@ -21,21 +24,5 @@ in
openFirewall = true; openFirewall = true;
}; };
systemd = { systemd = storage.systemd;
tmpfiles.rules = [
"d ${sourceDir} 0755 nobody nogroup -"
"z ${sourceDir} 0755 nobody nogroup -"
];
mounts = [
{
enable = true;
options = "bind,x-systemd.automount,nofail";
requires = [ "local-fs.target" ];
type = "none";
wantedBy = [ "multi-user.target" ];
what = "${sourceDir}";
where = "${targetDir}";
}
];
};
} }

Some files were not shown because too many files have changed in this diff Show More