diff --git a/modules/builder.nix b/modules/builder.nix index 4e5ed22..ee9525b 100644 --- a/modules/builder.nix +++ b/modules/builder.nix @@ -71,7 +71,6 @@ let homelab-garage homelab-notes homelab-hermes - homelab-silverbullet # Observability stack (server + VPN gated on "observability-server", # sender gated on "observability-sender"). @@ -141,9 +140,10 @@ let (noughtyHostModule name entry) # Record the git revision this config was built from, so every host # reports it via `nixos-version --configuration-revision` and (via - # the observability-sender textfile metric) into Mimir -- that's how - # cc-sync detects merged-but-not-deployed hosts. Dirty trees get the - # "-dirty" pseudo-rev (or null on nix without dirtyRev). + # the observability-sender textfile metric) into Mimir -- i.e. which + # rev each host actually runs, so a merged-but-not-deployed host is + # visible without ssh. Dirty trees get the "-dirty" pseudo-rev + # (or null on nix without dirtyRev). { system.configurationRevision = self.rev or self.dirtyRev or null; } ] ++ hmNixosBase diff --git a/modules/homelab/apps/silverbullet.nix b/modules/homelab/apps/silverbullet.nix deleted file mode 100644 index db78359..0000000 --- a/modules/homelab/apps/silverbullet.nix +++ /dev/null @@ -1,80 +0,0 @@ -# SilverBullet -- server-authoritative markdown notes + tasks, co-managed -# with Hermes (see plans/hermes-task-calendar.md). Split the same way as -# hermes.nix: traefik/dashboard registration on the reverse-proxy host (201), -# the actual service on 204-agent, colocated with Hermes so it can edit the -# space (plain .md files) with its terminal toolset -- no API, no replica. -{ inputs, ... }: -{ - flake.nixosModules.homelab-silverbullet = - { - config, - pkgs, - lib, - noughtyLib, - ... - }: - lib.mkMerge [ - # ── Traefik registration (runs on 201-mono) ─────────────────────────── - (lib.mkIf (noughtyLib.hostHasTag "reverse-proxy") { - phonkds.modules.silverbullet = { - ip = "192.168.3.204"; - port = 9121; - dashboard.enable = true; - dashboard.icon = "sh-silverbullet"; - traefik = { - enable = true; - domain = "silverbullet.w.phonkd.net"; - auth = false; - ipfilter = true; - }; - }; - }) - - # ── Service + space permissions + firewall (runs on 204-agent) ──────── - (lib.mkIf (config.noughty.host.name == "204-agent") { - # SB_USER=: (web basic-auth) + SB_AUTH_TOKEN (API - # bearer token) env lines, read by systemd as root. - sops.secrets."silverbullet-auth" = { }; - # The same API token alone, readable by hermes: page WRITES go - # through the localhost HTTP API (server-written files come out 640, - # so direct group writes onto server-created pages don't work); - # reads still go straight to the files via group r. - sops.secrets."silverbullet-token" = { owner = "hermes"; }; - - services.silverbullet = { - enable = true; - # Bind on all interfaces so traefik on 201 can reach it; the - # firewall rule below limits the port to 201-mono, like the - # hermes dashboard. - listenAddress = "0.0.0.0"; - listenPort = 9121; - # Keep the space at the module default. The nixpkgs module derives - # systemd's StateDirectory from the LAST path segment of spaceDir, - # so a nested dir like /var/lib/silverbullet/space would make - # systemd create /var/lib/space and never the real spaceDir. - spaceDir = "/var/lib/silverbullet"; - # The permissions bridge to Hermes: run silverbullet with hermes as - # its primary group so every page it writes is group-owned by - # hermes, and (with the umask below) group-writable. - group = "hermes"; - envFile = config.sops.secrets."silverbullet-auth".path; - }; - - systemd.services.silverbullet.serviceConfig = { - # StateDirectory defaults to 0755 -- hermes couldn't write. No - # setgid needed: both services' primary group is already hermes. - StateDirectoryMode = "0770"; - # Same value the hermes-agent module ships upstream (0007), so - # pages written by either side come out group-writable and the - # other can edit them. No world access. - UMask = "0007"; - }; - - networking.nftables.enable = true; - # Allow the silverbullet port from 201-mono only. - networking.firewall.extraInputRules = '' - ip saddr 192.168.3.201 tcp dport 9121 accept - ''; - }) - ]; -} diff --git a/modules/hosts/204-agent.nix b/modules/hosts/204-agent.nix index 7499f7e..454f890 100644 --- a/modules/hosts/204-agent.nix +++ b/modules/hosts/204-agent.nix @@ -88,12 +88,6 @@ # on a logged-in machine, no per-token API billing. REQUIRED before deploy: # sops-nix fails activation if this key is missing from secret.yaml. sops.secrets."hermes-claude" = { owner = "hermes"; }; - # Plaintext radicale password (the phonkd@phonkd.net account on - # cal.phonkd.net, see modules/hetzner/mail/mail.nix) -- vdirsyncer - # reads it with a password.fetch command straight from this path, so - # it's a bare password, NOT env-file format, and is deliberately not - # in environmentFiles. - sops.secrets."hermes-caldav" = { owner = "hermes"; }; services.hermes-agent = { enable = true; @@ -104,12 +98,10 @@ # (print mode + interactive PTY via tmux; jq parses its stream-json). # The skill auto-seeds into ~/.hermes/skills on startup; terminal + # skills toolsets are already in the hermes-discord preset. - # khal + vdirsyncer: the caldav skill's CLI pair (sync against - # radicale on cal.phonkd.net; config installed by the - # hermesCaldavConfig activation script below). curl: the SilverBullet - # HTTP API client used by the task-notes skill and the cc-sync script - # (NOT on the service PATH otherwise — only the system profile has it). - extraPackages = [ pkgs.gh pkgs.claude-code pkgs.tmux pkgs.jq pkgs.khal pkgs.vdirsyncer pkgs.curl ]; + # curl: used by skills that hit HTTP APIs directly (e.g. + # mimir-alerting) — NOT on the service PATH otherwise, only the + # system profile has it. + extraPackages = [ pkgs.gh pkgs.claude-code pkgs.tmux pkgs.jq pkgs.curl ]; # Main model: deepseek-v4-flash on OpenRouter (hermes-openrouter-key). # A Copilot/gpt-5.4 attempt is parked: the API path itself works — the # hermes-copilot OAuth token gets live gpt-5.4 completions from @@ -303,407 +295,6 @@ deps = [ ]; }; - # vdirsyncer + khal client config for the caldav skill below. Written - # into the hermes state dir (HOME for the hermes services) so both - # tools find it at their default paths. The remote is radicale on - # ext-mail (cal.phonkd.net, modules/hetzner/mail/mail.nix); the - # password is fetched at runtime from the hermes-caldav sops secret, - # so no credential lands in the nix store. - system.activationScripts.hermesCaldavConfig = - let - vdirsyncerConfig = pkgs.writeText "vdirsyncer-config" '' - [general] - status_path = "~/.vdirsyncer/status/" - - [pair phonkd_cal] - a = "phonkd_cal_local" - b = "phonkd_cal_remote" - collections = ["from a", "from b"] - metadata = ["displayname", "color"] - - [storage phonkd_cal_local] - type = "filesystem" - path = "~/.calendars/" - fileext = ".ics" - - [storage phonkd_cal_remote] - type = "caldav" - url = "https://cal.phonkd.net/" - username = "phonkd@phonkd.net" - password.fetch = ["command", "cat", "${config.sops.secrets."hermes-caldav".path}"] - ''; - khalConfig = pkgs.writeText "khal-config" '' - [calendars] - - [[phonkd]] - path = ~/.calendars/* - type = discover - - [locale] - timeformat = %H:%M - dateformat = %Y-%m-%d - longdateformat = %Y-%m-%d - datetimeformat = %Y-%m-%d %H:%M - longdatetimeformat = %Y-%m-%d %H:%M - ''; - in - { - text = '' - install -D -m 0644 \ - -o ${config.services.hermes-agent.user} -g ${config.services.hermes-agent.group} \ - ${vdirsyncerConfig} \ - ${config.services.hermes-agent.stateDir}/.config/vdirsyncer/config - install -D -m 0644 \ - -o ${config.services.hermes-agent.user} -g ${config.services.hermes-agent.group} \ - ${khalConfig} \ - ${config.services.hermes-agent.stateDir}/.config/khal/config - ''; - deps = [ ]; - }; - - # Custom Hermes skill: read/manage the personal calendar over CalDAV - # with khal + vdirsyncer (see plans/hermes-task-calendar.md). Installed - # like the skills above; the client config comes from - # hermesCaldavConfig. - system.activationScripts.hermesCaldavSkill = - let - skillFile = pkgs.writeText "SKILL.md" '' - --- - name: caldav - description: "Read and manage phonkd's personal calendar over CalDAV using vdirsyncer + khal. Read freely; create or modify only events explicitly asked for; never delete." - version: 1.0.0 - author: phonkd homelab - license: Unlicense - platforms: [linux, macos] - metadata: - hermes: - tags: [calendar, caldav, khal, vdirsyncer, homelab] - category: productivity - requires_toolsets: [terminal] - related_skills: [task-notes] - --- - - # CalDAV Calendar - - Read and manage the personal calendar that lives on the self-hosted - radicale server (`https://cal.phonkd.net/`). Everything happens - through two preinstalled CLIs: `vdirsyncer` (sync with the server) - and `khal` (read/create events on the local copy). - - ## When to Use - - - "What's on my calendar today/this week?", planning the day around - meetings (together with the `task-notes` skill). - - The user asks to create or change a calendar event. - - The 15m calendar cron tick: run `vdirsyncer sync` so the local - view stays fresh; surface anything starting soon if asked to. - - ## Hard rules - - - **Never `khal delete`** and never delete `.ics` files. If an event - must go away, tell the user and let them remove it. - - **Never bulk-reschedule.** Create/modify only the specific events - the user explicitly asked about. - - Reading is unrestricted. - - ## Before you start - - - Config is preinstalled: `~/.config/vdirsyncer/config` and - `~/.config/khal/config`. The local calendar copy syncs into - `~/.calendars/`. Credentials come from a sops secret referenced - inside the vdirsyncer config — never echo it. - - First run ever on a fresh state dir: `vdirsyncer discover` - (accept the collections), then `vdirsyncer sync`. - - ## Procedure - - - **Read:** `vdirsyncer sync` first (the local copy is stale - otherwise), then `khal list today 7d` (or `khal at`, `khal - calendar`). Always sync before answering questions about the - calendar. - - **Create** (only when asked): `khal new -a phonkd - ` (khal datetime format `%Y-%m-%d %H:%M`), then - `vdirsyncer sync` to push it to the server. - - **Modify** (only when asked): `khal edit ` is - interactive, so prefer editing the matching `.ics` under - `~/.calendars/` directly (find it by grepping for the SUMMARY), - bump its SEQUENCE and LAST-MODIFIED, then `vdirsyncer sync`. - - ## Verification - - - After create/modify: `khal list` shows the event with the right - time, and `vdirsyncer sync` exits 0 (the change reached the - server). - - Tell the user exactly what was created/changed, or `[SILENT]` on - a cron tick with nothing to report. - ''; - in - { - text = '' - install -D -m 0644 \ - -o ${config.services.hermes-agent.user} -g ${config.services.hermes-agent.group} \ - ${skillFile} \ - ${config.services.hermes-agent.stateDir}/.hermes/skills/productivity/caldav/SKILL.md - ''; - deps = [ ]; - }; - - # Custom Hermes skill: co-manage tasks/notes in the SilverBullet space - # served from this host (modules/homelab/apps/silverbullet.nix). The - # space is plain .md files that Hermes edits directly; SilverBullet - # re-indexes external writes, so no API is involved. - system.activationScripts.hermesTaskNotesSkill = - let - skillFile = pkgs.writeText "SKILL.md" '' - --- - name: task-notes - description: "Co-manage phonkd's tasks and notes in the SilverBullet space at /var/lib/silverbullet: read everything, plan by setting date attributes, stage new tasks to Inbox, never check off or delete tasks to clear a day." - version: 1.0.0 - author: phonkd homelab - license: Unlicense - platforms: [linux, macos] - metadata: - hermes: - tags: [tasks, notes, silverbullet, planning, homelab] - category: productivity - requires_toolsets: [terminal] - related_skills: [caldav, cc-sync] - --- - - # Task Notes (SilverBullet space) - - The user's notes and tasks live in a SilverBullet space: plain - markdown files under `/var/lib/silverbullet` on this host. This is - the ONE authoritative copy; there are no replicas to sync. - - - **Read** pages directly from the files (you have group read). - - **Write** pages through the server's localhost HTTP API — the - server owns the files and pages it writes are not group-writable, - so don't edit them in place. Full-page PUT: - - ```bash - curl -sS -X PUT "http://127.0.0.1:9121/.fs/.md" \ - -H "Authorization: Bearer $(cat /run/secrets/silverbullet-token)" \ - -H "Content-Type: text/markdown" \ - --data-binary @newcontent.md - ``` - - To change one line: read the file, build the full new page - content, PUT it back. Re-read the file afterwards to confirm it - landed. - - ## Space layout - - - **Project/note pages** (anywhere in the space): prose state with - inline tasks (`- [ ] thing to do [due: 2026-07-30] #sometag`). - Tasks belong next to their context, not in a central list. - - **`Backlog.md`** — a live SilverBullet query over every open task - in the space. Do not edit task lines here; edit the source page - the query pulled them from. - - **`Inbox.md`** — YOUR staging page. Anything new or uncertain - goes here for the user to triage into real pages. - - **`Journal/.md`** — daily pages. The day view is a - query over open tasks with `due` on-or-before that day, so - unfinished tasks carry forward automatically. - - **`Engineering.md`** — owned exclusively by the `cc-sync` skill. - Never write it from this skill. - - ## Task model - - - Open task: `- [ ] description`; done: `- [x] description`. - - Scheduling metadata is an inline attribute: `[due: 2026-07-30]`. - **Planning a day = setting/bumping that attribute** on existing - backlog tasks. Nothing moves, nothing closes. - - **Planning ≠ completion.** A date is a lens, not a bucket: a task - stays open until the USER checks it off. NEVER mark a task done - to "clear" a day, and never delete a task. Check one off only - when the user explicitly says it's done. - - ## Write rules (the autonomy envelope) - - - **Read**: the whole space, freely — that's your project context. - - **Write**: only what the user explicitly asked for (set/bump a - date attribute, check off a named task, append to a page they - named). Everything speculative goes to `Inbox.md` as an appended - `- [ ]` line with today's date and a short why. - - Never reorder or rewrite the user's prose, never delete pages, - never rename files. - - All writes via the HTTP API (above) — never write the space - files directly. - - ## Common operations - - - Every open task: - `grep -rn '^\s*- \[ \]' /var/lib/silverbullet --include='*.md'` - - Today's plate: the open tasks whose `[due: ...]` is on-or-before - today (compare the dates; absence of `due` = unscheduled - backlog). - - **Plan-my-day flow** (with the `caldav` skill): read today's - events with khal, read the open backlog (including `#cc` tasks - from `Engineering.md`), propose which tasks fit the gaps by - setting `[due: ]` on them, and present the proposal. Do - not invent time the calendar says doesn't exist; do not close - anything. - - ## Verification - - After an edit: re-read the file to confirm the change landed and - the markdown is intact (task lines still parse, attributes still - bracketed). Report exactly which pages/lines you touched. - ''; - in - { - text = '' - install -D -m 0644 \ - -o ${config.services.hermes-agent.user} -g ${config.services.hermes-agent.group} \ - ${skillFile} \ - ${config.services.hermes-agent.stateDir}/.hermes/skills/productivity/task-notes/SKILL.md - ''; - deps = [ ]; - }; - - # Custom Hermes skill: project nixconfig work-state (plans → PRs → - # deploys) into the Engineering page of the SilverBullet space. Pure - # read-derived projection: reads GitHub + Mimir, writes exactly one - # page it solely owns, touches nothing else. - system.activationScripts.hermesCcSyncSkill = - let - skillFile = pkgs.writeText "SKILL.md" '' - --- - name: cc-sync - description: "Project in-flight nixconfig work (unexecuted plans and open PRs) into the Engineering page of the SilverBullet space. Sole writer of that page; read-only against the repo; never merges or deploys." - version: 1.0.0 - author: phonkd homelab - license: Unlicense - platforms: [linux, macos] - metadata: - hermes: - tags: [nixconfig, tasks, automation, homelab] - category: devops - requires_toolsets: [terminal] - related_skills: [task-notes] - --- - - # Claude Code Work-State Sync - - In-flight engineering work is itself a task until it lands. Every - tick, rebuild `/var/lib/silverbullet/Engineering.md` as a - projection of TWO sources — unexecuted plans and open PRs. You are - the SOLE writer of that page; you never edit any other page, and - you never act on the repo or hosts — worst case is a stale task - line, corrected next tick. - - **No deploy-stage detection.** An earlier version tried to flag - "deploy " when a host's git rev differed from `origin/main`, - but `configurationRevision` is the git sha and bumps on EVERY - commit — docs, another host's change, a flake input one host uses — - so it flagged every host on nearly every commit. That was pure - noise and is intentionally removed. Do **not** query host revs, do - **not** emit `deploy ` lines, and do not use the - `nixos_configuration_revision` Mimir metric here (it still exists - for humans to eyeball a host's rev — just not for this page). Act - on the PR: merging + deploying is the human's job, and once a PR is - merged it drops off this page on the next tick. - - ## Stages (one task line per work item) - - | Stage | Detected from | Task line | - |---|---|---| - | planned | `plans/*.md` with Status draft/approved/in-progress | `- [ ] execute plan #cc` | - | PR open | `gh pr list` | `- [ ] review & merge PR #N: #cc` | - | merged | PR no longer open / plan status done | line disappears | - - A work item shows ONE line, at its most advanced stage: if a plan's - PR is open, show the PR line, not the plan line (match PR - branch/title against the plan topic; if unsure, keep both). A line - disappearing from the projection is the only way tasks here close — - that is correct for this page and does not violate the task-notes - no-checkoff rule, which governs user tasks. - - ## Sources - - - **Plans:** `gh api repos/phonkd/nixconfig/contents/plans --jq - '.[].name'`, then fetch each file and read its `**Status:**` - line. draft / approved / in-progress count; done/superseded do - not. - - **Open PRs:** `gh pr list --repo phonkd/nixconfig --state open - --json number,title,headRefName,isDraft` (drafts included). - - ## Procedure - - 1. Gather the two sources above. - 2. Build the page content: - - ```markdown - # Engineering - - <!-- Rewritten by the cc-sync skill every tick (sole writer: - Hermes). Don't edit by hand — act on the PR/plan instead; - this page follows reality on the next tick. --> - - - [ ] execute plan headscale-mesh #cc - - [ ] review & merge PR #47: silverbullet + task skills #cc - ``` - - 3. Write it through the SilverBullet localhost API (the server - owns the space files — never write them directly): - - ```bash - curl -sS -X PUT "http://127.0.0.1:9121/.fs/Engineering.md" \ - -H "Authorization: Bearer $(cat /run/secrets/silverbullet-token)" \ - -H "Content-Type: text/markdown" \ - --data-binary @newpage.md - ``` - 4. If the new content is identical to what was already there, - respond `[SILENT]` (cron tick, nothing changed). Otherwise - reply with a one-line diff summary ("PR #44 merged → dropped; - new plan headscale-mesh"). - - ## Hard rules - - - Sole writer of `Engineering.md`; never touch any other page. - - Read-only everywhere else: never `gh pr merge`, never push, - never `deploy`, never ssh into hosts. - - Don't invent work items: no source line, no task. - - ## Verification - - After writing: re-read `/var/lib/silverbullet/Engineering.md` and - confirm the PUT landed, the page is valid markdown, and every task - line ends with `#cc`. - ''; - in - { - text = '' - install -D -m 0644 \ - -o ${config.services.hermes-agent.user} -g ${config.services.hermes-agent.group} \ - ${skillFile} \ - ${config.services.hermes-agent.stateDir}/.hermes/skills/devops/cc-sync/SKILL.md - ''; - deps = [ ]; - }; - - # cc-sync as a DETERMINISTIC SCRIPT, not an LLM skill. The projection is - # purely mechanical (list plans, list open PRs, format, PUT to - # SilverBullet) and the cheap main model (deepseek-v4-flash) would not - # reliably follow it — verified live: it kept re-emitting the removed - # per-host "deploy <host>" lines regardless of skill/prompt edits. So the - # cc-sync cron job runs this script directly via `--no-agent --script - # cc-sync.sh` (that job config is runtime state in ~/.hermes/cron/jobs.json, - # set by hand like the other cron jobs — the SKILL.md above stays only as - # design documentation). Needs curl + gh on PATH (both in extraPackages) - # and reads GITHUB_TOKEN from the service env + the silverbullet-token - # secret at runtime. - system.activationScripts.hermesCcSyncScript = { - text = '' - install -D -m 0755 \ - -o ${config.services.hermes-agent.user} -g ${config.services.hermes-agent.group} \ - ${./cc-sync.sh} \ - ${config.services.hermes-agent.stateDir}/.hermes/scripts/cc-sync.sh - ''; - deps = [ ]; - }; - # Personal-data embedding + semantic search (the "thing"); Hermes queries # it via the MCP entry above. Postgres + pgvector are created locally. services.slop-trove = { diff --git a/modules/hosts/cc-sync.sh b/modules/hosts/cc-sync.sh deleted file mode 100644 index 27e1dc9..0000000 --- a/modules/hosts/cc-sync.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# cc-sync: deterministic projection of in-flight nixconfig work onto the -# SilverBullet "Engineering" page. NO LLM — the cheap main model wouldn't -# reliably follow the filtering, so this is a plain script run via -# `hermes cron ... --no-agent --script cc-sync.sh`. Sole writer of that page. -# -# Two sources only (deploy-stage detection was dropped on purpose — see -# plans/hermes-task-calendar.md): unexecuted plans + open PRs. On success it -# PUTs the page and prints a one-line summary; if the page is unchanged it -# prints nothing (empty stdout => cron delivers nothing). -set -uo pipefail - -REPO="phonkd/nixconfig" -SB="http://127.0.0.1:9121" -TOKEN_FILE="/run/secrets/silverbullet-token" - -# The `--no-agent` cron subprocess does NOT inherit the hermes-agent -# service's EnvironmentFile vars, so gh would run unauthenticated. Pull the -# token straight from its sops secret if it isn't already in the env. -if [ -z "${GITHUB_TOKEN:-}${GH_TOKEN:-}" ] && [ -r /run/secrets/hermes-github ]; then - set -a; . /run/secrets/hermes-github; set +a -fi - -command -v gh >/dev/null || { echo "cc-sync: gh missing" >&2; exit 1; } -command -v curl >/dev/null || { echo "cc-sync: curl missing" >&2; exit 1; } -[ -r "$TOKEN_FILE" ] || { echo "cc-sync: no silverbullet token" >&2; exit 1; } -TOKEN="$(cat "$TOKEN_FILE")" - -# --- gather sources; abort (leave page untouched) if GitHub is unreachable --- -plan_names="$(gh api "repos/$REPO/contents/plans" --jq '.[].name' 2>/dev/null)" \ - || { echo "cc-sync: gh plans list failed" >&2; exit 1; } -prs="$(gh pr list --repo "$REPO" --state open --json number,title \ - --jq '.[] | "- [ ] review & merge PR #\(.number): \(.title) #cc"' 2>/dev/null)" \ - || { echo "cc-sync: gh pr list failed" >&2; exit 1; } - -# --- build the page body --- -tmp="$(mktemp)" -{ - echo "# Engineering" - echo - echo "<!-- Rewritten by cc-sync (deterministic script, sole writer). Don't" - echo "edit by hand — act on the plan/PR; this page follows reality next tick." - echo "Projection of unexecuted plans + open PRs only; no deploy detection. -->" - echo - - # Plans whose status is NOT finished. - while IFS= read -r name; do - case "$name" in *.md) ;; *) continue ;; esac - body="$(gh api "repos/$REPO/contents/plans/$name" --jq '.content' 2>/dev/null \ - | tr -d '\n' | base64 -d 2>/dev/null)" || continue - status_line="$(printf '%s\n' "$body" | grep -im1 'status' || true)" - # Finished states are excluded; everything else (incl. no status) is shown. - if printf '%s' "$status_line" \ - | grep -qiE 'done|deployed|superseded|complete|abandon|shipped|dropped'; then - continue - fi - echo "- [ ] execute plan ${name%.md} #cc" - done <<< "$plan_names" - - # Open PRs. - [ -n "$prs" ] && printf '%s\n' "$prs" -} > "$tmp" - -# --- write only if changed --- -cur="$(curl -s -H "Authorization: Bearer $TOKEN" "$SB/.fs/Engineering.md" 2>/dev/null || true)" -new="$(cat "$tmp")" -if [ "$new" = "$cur" ]; then - rm -f "$tmp" - exit 0 # unchanged -> silent -fi - -code="$(curl -sS -X PUT "$SB/.fs/Engineering.md" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: text/markdown" \ - --data-binary @"$tmp" -o /dev/null -w '%{http_code}')" -rm -f "$tmp" -if [ "$code" != "200" ]; then - echo "cc-sync: PUT failed (HTTP $code)" >&2 - exit 1 -fi -plans_n="$(printf '%s\n' "$new" | grep -c 'execute plan')" -prs_n="$(printf '%s\n' "$new" | grep -c 'review & merge')" -echo "cc-sync: updated Engineering — ${plans_n} plan(s), ${prs_n} open PR(s)" diff --git a/modules/observability.nix b/modules/observability.nix index b7dec3f..8ce6e7f 100644 --- a/modules/observability.nix +++ b/modules/observability.nix @@ -579,8 +579,8 @@ # Expose the running config's git revision as a metric via the textfile # collector above. `nixos_configuration_revision{revision="<sha>"} 1` - # per host is what cc-sync (on 204-agent) compares against origin/main - # to spot merged-but-not-deployed hosts -- no ssh needed. The file is a + # per host shows which rev each host actually runs, so comparing it to + # origin/main spots merged-but-not-deployed hosts -- no ssh. The file is a # store symlink, which the textfile collector reads fine; it changes # exactly when a new generation is activated. environment.etc."prometheus-textfiles/nixos-configuration-revision.prom".text = diff --git a/plans/hermes-task-calendar.md b/plans/hermes-task-calendar.md deleted file mode 100644 index 97f837f..0000000 --- a/plans/hermes-task-calendar.md +++ /dev/null @@ -1,280 +0,0 @@ -# Hermes co-managed tasks + calendar (SilverBullet) - -**Repo(s):** `nixconfig`. Service + Hermes skills land on **`204-agent`**; the -traefik route registers on **`201-mono`** (same cross-host split as -`hermes.nix`). The notes/tasks live in a SilverBullet *space* (markdown files) on -`204-agent`; the calendar stays on the existing self-hosted CalDAV server. -**Status:** deployed (nix wiring live on 201/204; runtime one-time steps -pending — see bottom). - -## Remaining runtime steps (2026-07-23) - -The nix side is committed to `main` (`b82b7e4`) and deployed to 201-mono + -204-agent; 203/205 redeployed so all four LAN hosts now report -`nixos_configuration_revision` into Mimir (verified). Still to do by hand: - -1. **Set the real radicale password** — `hermes-caldav` is a placeholder. - It's the plaintext `phonkd@phonkd.net` password (the account behind - `cal.phonkd.net`): `sops set modules/homelab/global-secrets/secret.yaml - '["hermes-caldav"]' '"<password>"'` then `deploy 204`. -2. **vdirsyncer first sync** (as the hermes user on 204): `vdirsyncer - discover` (accept collections) → `vdirsyncer sync` → `khal list`. -3. **Seed the query pages** `Backlog` / `Today` in the SilverBullet PWA - (do it in-app to get SB 2.6 Space-Lua query autocomplete rather than - hand-writing syntax). `Inbox` + `Engineering` are already seeded. -4. **Create the two `hermes cron` jobs** (like the autofix job): one runs - the `caldav` skill (`vdirsyncer sync` + surface today), one runs - `cc-sync` to rewrite `Engineering`. -5. **PWA login** on phone + desktop (basic-auth creds = - `silverbullet-auth` secret; the generated password was reported in-chat). -6. **observability host** wasn't redeployed (its `id_rsa` needs a - passphrase only you have) — `deploy observability` once so it also - reports its rev; until then cc-sync lists it as "rev unverifiable". - -Implementation notes that differed from the draft: -- **spaceDir stays at the module default** `/var/lib/silverbullet` — the - nixpkgs module derives systemd's `StateDirectory` from the *last* path - segment, so a nested `.../space` would make systemd create `/var/lib/space` - and never the real dir. -- **Hermes writes pages via the localhost HTTP API** (`PUT /.fs/<page>` - with an `SB_AUTH_TOKEN` bearer), not direct file writes: server-created - pages come out `0640`, so hermes can read them but can't edit in place. - Reads are still plain file reads. - -## Goal - -Manage my calendar and tasks **together with** Hermes — equal access, not -delegation. I write detailed prose state for tasks/projects, so tasks must live -**next to my notes** (note-adjacency). I also want **one big list of every open -task** plus a **daily list** where either of us picks what fits the day. And it -has to survive an agent and me writing concurrently, from any device including -the phone. - -**Why not Obsidian (the pivot):** Obsidian is a single-user editor over flat -files. Every hard thing in the earlier drafts — git merge layers, LiveSync, -sync-conflict files, append-only conventions — existed only to make *two replicas -+ dumb sync* fake a shared datastore. That's the real defect: wrong storage -model. Switching to another flat-file PKM (Logseq, Joplin, Foam) changes nothing. - -**The fix: a server-authoritative notes tool — SilverBullet.** It keeps -note-adjacent markdown, but there is **one authoritative copy** (a `spaceDir` of -`.md` files on one server) that my phone (PWA), my desktop, and Hermes all talk -to — not N replicas that need merging. That single fact deletes the entire -conflict-layer question. On top of that SilverBullet gives: - -- **Note-adjacency** — I write full project state in a page; tasks (`- [ ]`) live - inline in that page with the prose around them. -- **A query/index engine** — it indexes tasks across the whole space, so "one big - list of every open task" and "today" are **live queries**, not files I hand- - maintain. My backlog+daily model is native, and note-adjacency stops fighting - the one-big-list want: tasks live where they belong; the queries assemble them. -- **Trivial Hermes access** — the space is plain `.md` files *on the same host as - Hermes* (`204-agent`). Hermes reads/writes them with the terminal toolset it - already has — no MCP tool, no API, no replica. (`services.silverbullet` is in - our nixpkgs: `silverbullet-2.6.1`, module `services.silverbullet` with - `spaceDir`/`listenPort`/`listenAddress`/`envFile`.) - -## Approach - -### Notes + tasks (SilverBullet on `204-agent`) - -1. **Run SilverBullet on `204-agent`**, colocated with Hermes, following the - `hermes.nix` cross-host structure (not the generic `homelab-server` gate, - because `204-agent` isn't that tag): - - On **`reverse-proxy`** (201-mono): register `phonkds.modules.silverbullet` - with `ip = "192.168.3.204"`, the port, a dashboard tile, and a traefik route - (`silverbullet.w.phonkd.net`, `ipfilter = true`). - - On **`204-agent`**: `services.silverbullet.enable = true` with - `listenAddress = "0.0.0.0"`, a `listenPort`, `spaceDir = - /var/lib/silverbullet/space`, and `envFile` (basic-auth creds from a sops - secret). Add an nftables rule allowing that port from `192.168.3.201` only — - copy the hermes-dashboard firewall pattern. -2. **Bridge the space to Hermes.** SilverBullet runs as its own user; Hermes runs - as `hermes`. Give both read/write on `spaceDir` — simplest is to set - `services.silverbullet.group = "hermes"` (or a shared group) and make the space - group-writable, so Hermes edits pages directly. SilverBullet watches the space - and re-indexes external file changes, so Hermes writing `.md` files straight to - disk is a supported path. -3. **Task model in the space:** - - Tasks are inline `- [ ]` items inside project/note pages, with attributes for - scheduling (SilverBullet task attributes, e.g. a `due`/`scheduled` date or a - `#today` tag). - - A **`Backlog`** page holds a live query: every open task across the space. - - A **daily page** (`Journal/YYYY-MM-DD` or a `Today` page) holds a query for - **open tasks scheduled on-or-before today** — so anything planned but not - finished on its day *carries forward* and keeps showing until it's done, - never dropped. **Planning the day** — me or Hermes — is just *setting the date - attribute* on backlog tasks; the day view assembles itself. - - **Planning ≠ completion.** Scheduling a task for a day only sets metadata; the - task stays open until *I* check it off. A day is a lens over open tasks, not a - bucket that consumes them. The `cc-sync` deploy-stage tasks obey the same rule - — they close only when the change is actually live. This is the invariant the - `task-notes` skill enforces: Hermes may set/bump dates when planning, but - **never checks a task off to "clear" a day.** -4. **Propose, don't mutate.** Hermes drops new/uncertain tasks onto an **`Inbox`** - page; I triage them into their real project pages. It may set date attributes / - check off only tasks I explicitly asked about. Read = the whole space; write is - governed by the skill rules below. - -### Calendar (CalDAV — unchanged from the earlier draft) - -1. `pkgs.khal` + `pkgs.vdirsyncer` in `services.hermes-agent.extraPackages`. -2. `vdirsyncer`/`khal` config in the hermes state dir; creds from sops secret - `hermes-caldav` (`owner = "hermes"`, in `environmentFiles`). -3. A `caldav` skill: `vdirsyncer sync` → `khal list` to read, `khal new` to - create, edit `.ics` + sync to push. Read unrestricted; **create/modify only for - events I asked for; never `khal delete`, never bulk-reschedule.** - -### Claude Code work-state sync (plans → PRs → deploys → tasks) - -The missing piece: **in-flight engineering work is itself a task, at every stage -until it's actually live** — which is exactly the `done-means-deployed` rule -encoded as task state. A single nixconfig change moves through: - -| Stage | Detected from | Task reads | -|---|---|---| -| planned, not executed | `plans/*.md` with `Status:` draft/approved/in-progress | "execute plan `<topic>`" | -| executed, PR open, not merged | `gh pr list --state open` (incl. `hermes-autofix`'s own draft PRs) | "review & merge PR #N: `<title>`" | -| merged, not deployed | host's deployed rev ≠ `origin/main` | "`deploy <host>`" | -| deployed / verified | host rev == `origin/main` | task closes | - -This is a **projection**, not a hand-maintained list. A separate **`cc-sync`** -skill on its own `hermes cron` job (every ~15m) polls those three sources and -**rewrites a dedicated `Engineering` page** in the space — Hermes is the *sole -writer* to that page, so it never conflicts with my edits, and I act on the -PR/deploy, not the task line (which updates next tick). Tasks there are tagged -`#cc` so the `Backlog` query can fold them in or filter them out. - -Detection notes: - -- **Plans / PRs** need no new plumbing — `Status:` is already parseable and Hermes - has `gh`/`GITHUB_TOKEN`. -- **Merged-but-not-deployed** is the one gap: **set `system.configurationRevision` - in the flake** (currently unset) so every host reports its running rev via - `nixos-version`. Then Hermes learns each host's deployed rev **without ssh** by - querying Mimir — expose the rev as a node metric label (textfile collector) and - Hermes reads it the same way the `mimir-alerting`/`hermes-autofix` skills already - hit `10.9.0.1`. (ssh-per-host is the fallback if the metric route is more work - than it's worth.) - -### The Hermes skills - -Two skills, both installed via activation script like -`mimir-alerting`/`hermes-autofix`: - -- **`task-notes`** — the SilverBullet space layout, task syntax + date attributes, - the `Backlog`/daily/`Inbox` pages, the "planning = set a date attribute" - workflow, and the read-all / stage-to-`Inbox` / write-only-what-I-asked rule. - Editing pages = plain file ops under `spaceDir`. -- **`cc-sync`** — the projection above: poll plans/PRs/deploys and rewrite the - `Engineering` page. Sole writer to that page; read-only against the repo/hosts. - -These are the pieces I'll iterate on most — `SKILL.md` edits, not redeploys. - -### Keeping it in step - -Two `hermes cron` jobs (created once by hand like the autofix job): one `every -15m` runs `vdirsyncer sync` so `khal`'s view is fresh and can surface today's -overdue items; a second drives `cc-sync` to rewrite the `Engineering` page. -`[SILENT]` when nothing's up. - -The morning tick is the **calendar-aware "plan my day"** flow, and it's the payoff -of colocating tasks + calendar under one agent: Hermes reads the day's CalDAV -events (`khal`) *and* the open `Backlog` (including carried-over and `#cc` deploy -tasks), then proposes a realistic day by *setting date attributes* on the tasks -that fit the gaps around my commitments — it doesn't invent time I don't have. I -adjust; nothing gets closed by planning. Whatever I don't finish is still open -tomorrow. - -## Steps - -1. **`nixconfig` / new `modules/homelab/apps/silverbullet.nix`** — the cross-host - module: traefik registration on `reverse-proxy`, `services.silverbullet` + - firewall rule on `204-agent`, group bridge to `hermes`. Model on `hermes.nix`. -2. **`nixconfig` / `204-agent.nix`** — `pkgs.khal` + `pkgs.vdirsyncer` to - `extraPackages`; sops secrets `hermes-caldav` and `silverbullet-auth` - (`owner`/env as needed); append caldav secret to `environmentFiles`. Add both - secrets to `secret.yaml`. -3. **`nixconfig`** — activation scripts writing the `vdirsyncer`/`khal` config + - the `caldav`, `task-notes`, and `cc-sync` `SKILL.md` files into the hermes - state dir. -4. **`nixconfig` (for `cc-sync`)** — set `system.configurationRevision` from the - flake `self.rev`, and expose it as a node metric (textfile collector) so Hermes - can read each host's deployed rev from Mimir. (Small, generally-useful change: - you can then always see what rev a host runs.) -5. **Deploy** `deploy 204-agent` (and `deploy 201-mono` for the traefik route); - redeploy hosts once so they report `configurationRevision`. -6. **Runtime (one-time)** — `vdirsyncer discover`/first sync; seed the SilverBullet - space with `Backlog`, `Today`, `Inbox`, `Engineering` pages + their queries; - create the two `hermes cron` jobs; log into the SilverBullet PWA on - phone/desktop. -7. **Verify** — `khal list` shows real events; I add a task in a note on the phone - and it appears in the `Backlog` query; Hermes sets a `due:today` on one and it - shows on the daily page; Hermes and I edit the same page seconds apart and the - server reconciles to one page (**no `.sync-conflict` anywhere**); this very - plan/PR shows up on the `Engineering` page as a task, and advances stage when it - merges and again when `204-agent` is deployed. - -## Open decisions - -Defaults picked; say if you'd override: - -- **Notes tool = SilverBullet** (recommended) vs. Trilium/TriliumNext (server DB + - REST API, but not markdown and tasks aren't first-class) vs. staying on Obsidian - with LiveSync/CouchDB (keeps the editor, but Hermes access is clunkier and it's - a heavier service). SilverBullet uniquely gives markdown note-adjacency + a task - query engine + plain-file Hermes access on one authoritative store. -- **Host placement = `204-agent`, colocated with Hermes** (recommended) — gives - Hermes direct filesystem access to the space. Alternative: run it on a - `homelab-server` host and have Hermes reach it over the HTTP API — cleaner tag - fit, but loses the plain-file access that makes the skill trivial. -- **Hermes edit path = direct file ops on `spaceDir`** (recommended; SilverBullet - re-indexes external changes) vs. the SilverBullet **HTTP API**. Direct files is - simplest; switch to the API only if we ever see a race writing the same page. -- **CalDAV access = `khal`/`vdirsyncer` CLI** (recommended) vs. raw `curl` vs. a - `caldav` MCP server — unchanged rationale. -- **Deployed-rev detection = `configurationRevision` + Mimir metric** - (recommended) vs. Hermes ssh-ing each host. The metric route reuses the - Mimir-query capability Hermes already has and needs no ssh keys on `204-agent`; - ssh is the fallback. -- **`Engineering` tasks: separate `#cc` page, one task per work-item advancing - through stages** (recommended) vs. a task per stage, and vs. folding them - straight into `Backlog`. One-item-with-stages keeps a change as a single line - from plan→PR→deploy; the `#cc` tag lets me include/exclude them from the big - list at will. -- **Whole-space read exposure (accepted)** — Hermes can read every page, chosen so - it has project context. Write restraint rests on the skill rules + `Inbox` - staging. -- **Autonomy envelope** (matches the `hermes-autofix` house rule): read - everything; write only what I asked; stage speculative items to `Inbox`; **no** - `khal delete`, **no** autonomous rescheduling, **no** deleting tasks. - -## Risks / rollout - -- **Concurrent edits** — the whole reason for SilverBullet. One authoritative - on-disk space + a re-indexing server means there's no two-replica merge and no - `.sync-conflict` files. Residual surface: Hermes and a client writing the *exact - same page* in the same instant is last-write on one store (not a fork); the - `Inbox`-staging + set-attributes-not-reorder convention keeps even that near - zero. Far smaller than the Obsidian model. -- **Space is not in this repo.** The plan + nix wiring are versioned here; the - notes/tasks live in the SilverBullet space, the calendar in CalDAV. Both are - worth their own backup (a periodic `git`/`restic` snapshot of `spaceDir`) since - they're now a primary source of truth on `204-agent`, not just a replica. -- **Whole-space read exposure** — see the decision above. -- **`cc-sync` is read-derived** — it only *reads* plans/PRs/host-revs and - *writes* the one `Engineering` page it solely owns; it never merges, deploys, or - edits other pages. Worst case is a stale/wrong task line, corrected next tick — - no action taken on the repo or hosts. Requires `configurationRevision` to be set - or the deploy-stage tasks are blind. -- **Permissions bridge** — silverbullet-user vs hermes-user access to `spaceDir` - is the one fiddly bit; get the shared group + group-writable dir right or Hermes - can't write. -- **Rollout** is additive: `deploy 204-agent` + `deploy 201-mono`. Nothing else on - the reverse proxy changes beyond one route. Back out by removing the - silverbullet module, the two `extraPackages`, the secrets, and the activation - scripts, then redeploy; the space dir + cron job are runtime state removed by - hand. -- **Credential blast radius** — `hermes-caldav` = full calendar write; - SilverBullet basic-auth guards the notes. Scope each as tightly as the servers - allow; the traefik `ipfilter` already gates network reach.