From 2a23e82169d3b3231246b84dd574facc30d55e59 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 4 Jul 2026 20:34:11 +0100 Subject: [PATCH] =?UTF-8?q?feat(doctor):=20nomarchy-doctor=20=E2=80=94=20r?= =?UTF-8?q?ead-only=20health=20sheet=20+=20VM=20check=20(item=2010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The things that actually break user machines, one command (and menu › System › Doctor): failed system/user units, disk space (real filesystems only — fstype allowlist makes it VM/live safe, device dedupe collapses / and /nix), theme-state.json parses + is git-tracked, flake dirty/behind as warnings, generation age (profile symlink mtime — store paths are epoch-1), snapper timeline when enabled. Read-only by contract; every ✖ prints the one fixing command; exit 1 on any problem. Ships as pkgs/nomarchy-doctor so the VM check runs on a minimal node. Verified: V1 package build (shellcheck-gated) + a real-hardware smoke that correctly flagged a genuinely failed user unit on the dev box; V2 green — checks.doctor VM test: induced failed unit flips the sheet to exit-1 naming the unit and its fix, reset-failed returns healthy. Co-Authored-By: Claude Fable 5 --- .claude/skills/use-gemini/SKILL.md | 168 ++++++++++++++++++++++++ README.md | 1 + agent/BACKLOG.md | 13 +- agent/HARDWARE-QUEUE.md | 4 + agent/JOURNAL.md | 21 +++ flake.nix | 29 ++++ modules/home/rofi.nix | 8 ++ modules/nixos/default.nix | 1 + pkgs/nomarchy-doctor/default.nix | 10 ++ pkgs/nomarchy-doctor/nomarchy-doctor.sh | 138 +++++++++++++++++++ 10 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 .claude/skills/use-gemini/SKILL.md create mode 100644 pkgs/nomarchy-doctor/default.nix create mode 100644 pkgs/nomarchy-doctor/nomarchy-doctor.sh diff --git a/.claude/skills/use-gemini/SKILL.md b/.claude/skills/use-gemini/SKILL.md new file mode 100644 index 0000000..1fbd02a --- /dev/null +++ b/.claude/skills/use-gemini/SKILL.md @@ -0,0 +1,168 @@ +--- +name: use-codex +display_name: Use Codex +description: Spawn one or more OpenAI Codex CLI subagents from bash to offload context-heavy work from the parent Claude. Use when the user says "use codex", "build with codex", "ask codex", "get a codex second opinion", "compare codex vs claude", "have two agents try it", or otherwise asks to delegate or parallelize work to Codex. Also use whenever a user asks for a large coding task — research, multi-file refactors, large codebase analysis. +builtIn: true +--- + +# Codex Subagent Skill + +Spawn autonomous Codex CLI subagents to offload context-heavy work. Subagents burn their own tokens and return only their final message, so the parent's context stays clean. + +**Golden Rule:** If task + intermediate work would add 3,000+ tokens to parent context → use a subagent. + +## Instructions + +When invoking this skill: + +1. **Clarify intent.** Figure out what the user wants. If unclear, ask. Inline args ("use codex to review my auth plan") usually carry the intent — infer from them. Common buckets: second-opinion code review, refactor, plan validation, implementation of features, fresh perspective on a stuck bug, parallel comparison of approaches. +2. **Pick the right reasoning tier** Use GPT 5.5 model with either low or xhigh reasoning +3. **Spawn the subagent** using the canonical invocation (Basic Usage). Pipe long prompts via stdin. +4. **Act autonomously while it runs.** Don't ask for permission mid-flight; the parent only sees the final result, so mid-task pauses waste tokens. Pause only for genuinely destructive operations (data loss, external impact, security). +5. **Monitor, don't fire-and-forget.** Check completion, verify quality, retry on failure, answer follow-ups if blocked. Feel free to run multiple sequential or parallel codex subagents. +6. **Present results, don't dump them.** Summarize what Codex said in your own words, surface concrete code changes or recommendations, and leave the next move to the user. Subagent output is *input* for your synthesis. Keep your response to the user concise and specific. + +## Model + Reasoning Selection + +Always use GPT 5.5 (latest), but choose from low or xhigh reasoning. You may use other reasoning but usually simple, short, or basic tasks are best done by low reasoning and hard, large, or long tasks are done by xhigh reasoning. + +## Intelligent Prompting + +Subagents only see what you give them. Be specific: + +1. **Context** — what they're analyzing, where it lives. +2. **Objectives** — numbered, concrete. +3. **Constraints** — what to focus on, what to ignore. +4. **Output format** — exact shape the parent wants back. +5. **Success criteria** — when the task is done. + +Template: + +``` +[TASK CONTEXT] +You are researching/analyzing/coding [TOPIC/EXPLANATION]. + +[OBJECTIVES] +1. ... +2. ... + +[CONSTRAINTS] +- Focus on: ... +- Ignore: ... + +[OUTPUT FORMAT] +Return: ... + +[SUCCESS CRITERIA] +Complete when: ... +``` + +### Good vs. Bad Prompts + +Vague prompts produce vague work; specific prompts produce useful work. + +**Research** + +❌ "Research authentication" + +✅ "Research authentication in this Next.js codebase. Focus on: 1) Session management strategy (JWT vs session cookies), 2) Auth provider integration (NextAuth, Clerk, etc), 3) Protected route patterns. Check /app, /lib/auth, and middleware files. Return architecture summary with code examples." + +**Web search / docs lookup** + +❌ "Search for Codex SDK" + +✅ "Find the most recent Codex SDK documentation using the web and summarize key updates. Focus on: 1) Installation/quickstart, 2) Core API methods and parameters, 3) Breaking changes or deprecations. Prioritize official OpenAI docs and release notes. Return a concise summary with citations." + +**API discovery** + +❌ "Find API endpoints" + +✅ "Find all REST API endpoints in this Express.js app. Look in /routes, /api, and /controllers directories. For each endpoint document: method (GET/POST/etc), path, auth requirements, request/response schemas. Return as markdown table." + +## Basic Usage + +**Default: pipe the prompt via stdin using `-` as the positional argument.** Inline string prompts work for short ones, but anything with newlines, quotes, backticks, or `$` should be piped to avoid shell-escaping bugs. + +Canonical invocation (capture output to file, GPT 5.5 with xhigh reasoning): + +```bash +cat <<'EOF' | codex exec --yolo --skip-git-repo-check \ + -m gpt-5.5 -c 'model_reasoning_effort="xhigh"' \ + -o /tmp/codex-result.txt - +[TASK CONTEXT] +You are analyzing /path/to/repo. + +[OBJECTIVES] +1. Do X +2. Do Y + +[OUTPUT FORMAT] +Return: path - purpose +EOF + +result=$(cat /tmp/codex-result.txt) +``` + +**Variants** (only what changes from the canonical form): + +- **Low-effort task** (simple search, short fetch, basic lookup): swap `-c 'model_reasoning_effort="xhigh"'` → `-c 'model_reasoning_effort="low"'`. Model stays `gpt-5.5`. +- **Machine-parsable output:** swap `-o /tmp/codex-result.txt` → `--json`, then pipe through `jq -r 'select(.event=="turn.completed") | .content'`. Prefer `-o` whenever possible — it skips JSON parsing and avoids terminal truncation on long outputs. + +## Parallel Subagents + +Spawn multiple subagents for independent tasks — research two topics in parallel, or compare two approaches to the same problem. Each writes to its own `-o` file; `wait` for all, then read: + +```bash +cat <<'EOF' | codex exec --yolo --skip-git-repo-check \ + -m gpt-5.5 -c 'model_reasoning_effort="xhigh"' -o /tmp/agent-a.txt - & +Approach A: solve [problem] using [strategy A]. Return diff + rationale. +EOF + +cat <<'EOF' | codex exec --yolo --skip-git-repo-check \ + -m gpt-5.5 -c 'model_reasoning_effort="xhigh"' -o /tmp/agent-b.txt - & +Approach B: solve [problem] using [strategy B]. Return diff + rationale. +EOF + +wait +RESULT_A=$(cat /tmp/agent-a.txt) +RESULT_B=$(cat /tmp/agent-b.txt) +``` + +The canonical compare-two-approaches pattern: give both subagents the same problem under different framings, wait, then synthesize the better answer in the parent. + +## Sequential Subagents + +When the parent is driving a multi-step engagement — each next call is a *judgment* based on what just came back, not a mechanical retry. Spawn one subagent, read its output, decide what to do next (next slice of the work, a review pass, a redo, a different angle, a verification step), spawn the next one, repeat. The parent stays in the driver's seat the whole time: + +```bash +# Step 1: dispatch the first subagent +cat <<'EOF' | codex exec --yolo --skip-git-repo-check \ + -m gpt-5.5 -c 'model_reasoning_effort="xhigh"' \ + -o /tmp/codex-step-1.txt - +Implement the auth middleware in src/middleware/auth.ts per the spec at docs/auth.md. +Return: summary of changes + files touched. +EOF + +# Step 2: parent reads /tmp/codex-step-1.txt, decides what's needed next. +# Could be: next part of the task, a critique pass, a redo, a verification — whatever fits. +# Always use a quoted heredoc (<<'EOF') for the template and `cat` the prior +# output on stdin — never interpolate $STEP_N into an unquoted heredoc. +# Codex output frequently contains backticks and $() sequences (code blocks, +# command examples), and bash would execute those during expansion. +{ + cat <<'EOF' +Review the auth middleware changes summarized below for security holes +(token leakage, timing attacks, missing CSRF). Return: issues + line refs. + +[PRIOR WORK] +EOF + cat /tmp/codex-step-1.txt +} | codex exec --yolo --skip-git-repo-check \ + -m gpt-5.5 -c 'model_reasoning_effort="xhigh"' \ + -o /tmp/codex-step-2.txt - + +# Step 3: parent reads /tmp/codex-step-2.txt, dispatches a fix pass — or moves +# on to the next slice of work entirely. Keep going until the parent decides done. +``` + +The shape: **call → read → decide → call → read → decide**. Each prompt is composed fresh in the parent based on what just landed. There's no fixed loop count and no shared template — every step can be a totally different task (implement, review, refactor, sanity-check, redo from scratch). Feed prior outputs into later prompts whenever the next subagent needs that context to do its job. diff --git a/README.md b/README.md index 595b79d..801cfb7 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,7 @@ nomarchy-theme-sync get colors.accent sys-update # update inputs + rebuild the system (snapshots first) sys-rebuild # rebuild the system, current lock (no update) home-update # rebuild just the desktop layer +nomarchy-doctor # read-only health sheet (also: menu › System › Doctor) ``` Something broke anyway? Every rebuild is a generation and (on BTRFS) diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index d2177d7..00df14e 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -26,16 +26,6 @@ next, in what order*. ## NEXT -### 10. `nomarchy-doctor` — one-shot health check -New. A single command (+ System-menu entry) that checks the things that -actually break user machines and prints a themed pass/fail sheet: -failed systemd units (system + user), disk space (/, /boot, the nix -store), state-file parses + is git-tracked, downstream flake dirty/ -diverged, last rebuild generation age, snapper timeline running (when -enabled). Read-only, no auto-fixing; each failure prints the one command -that fixes it. Optionally later: a self-gating Waybar warning fed by the -same script. **Verify:** V2 — a VM check with an induced failure. - ### 11. State-file validation & friendly errors New; "the user never has to master Nix" must include *error messages*. A hand-edited theme-state.json (trailing comma, wrong type, unknown @@ -126,6 +116,9 @@ earns ≥3 entries — keep the root at six. - **MIPI/IPU software-ISP camera** support (no-UVC machines). - **OCR screenshot-to-text**: a Capture › entry (grim region → tesseract → clipboard) — cheap once recording (#12) reshapes the submenu. +- **Doctor Waybar warning**: a self-gating bar indicator fed by + `nomarchy-doctor` (shipped 2026-07-04) — appears only when the sheet + has a ✖; click opens the sheet. - **Auto-timezone Waybar tooltip** showing the detected zone (optional). - **VPN exit-node richer display** (country/city) (optional). - **NixOS release bump → v2** `[human]`: deliberate, hand-edited, never diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 0d6dfac..279c86d 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -86,6 +86,10 @@ QA machine), the **T14s** (webcam case). `sudo nomarchy-snapshots`, restore a single file (`undochange`) and walk a root-config rollback up to (or through) the typed-`yes` gate. +- [ ] **Doctor (item 10)** — menu › System › Doctor opens the sheet in + a terminal; `nomarchy-doctor` over SSH shows the same minus user + units. On a healthy machine everything is ✔/– (dev-box run + 2026-07-04 correctly flagged a genuinely failed user unit). - [ ] **Rollback menu (item 9b)** — after a couple of theme changes, menu › System › Rollback: recent desktop generations listed (newest marked current); picking an older one opens a terminal, diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 9ee8aa5..911f43e 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -17,6 +17,27 @@ Template: --- +## 2026-07-04 — nomarchy-doctor (iteration #20, item 10) +- **Task:** BACKLOG NEXT#10 — one-shot read-only health check. +- **Did:** pkgs/nomarchy-doctor (writeShellApplication → shellcheck- + gated; a package so the VM check runs on a minimal node): failed + system+user units (user bus self-skips), disk space on real + filesystems only (fstype allowlist skips tmpfs/9p → VM-safe; device + dedupe), state-file parses + git-tracked, flake dirty/behind + (warn-level), generation age via the profile SYMLINK mtime (store + paths are epoch-1), snapper timer when enabled. Every ✖ prints its + fix; exit 1 on any ✖. Wired: overlay, systemPackages, System › + Doctor menu row (terminal), README §5. +- **Verified:** V0; V1 package build (shellcheck) + real-hardware + smoke: correctly flagged a genuinely failed user unit on the dev box; + V2 GREEN — VM test: induced failed unit → exit 1 + names it + prints + fix; reset-failed → healthy exit 0. Menu rebuild bash -n ok. +- **Pending:** V3 menu-row check queued. Waybar-warning follow-up → + LATER. +- **Next suggestion:** NEXT#11 (state-file validation) — doctor's + "parses" check is its little sibling; 11 adds validate-before-write + + eval-time schema messages. + ## 2026-07-04 — System › Rollback menu (iteration #19, item 9b → item 9 done) - **Task:** BACKLOG NEXT#9 half (b) — undo one menu away. - **Did:** new `rollback` case in nomarchy-menu (System submenu row): diff --git a/flake.nix b/flake.nix index 20b074d..925674e 100644 --- a/flake.nix +++ b/flake.nix @@ -83,6 +83,7 @@ # works on machines that don't check out this repo. themesDir = ./themes; }; + nomarchy-doctor = final.callPackage ./pkgs/nomarchy-doctor { }; }; nixosModules.nomarchy = { @@ -183,6 +184,34 @@ touch $out ''; + # nomarchy-doctor's contract: an induced failed unit flips the + # sheet to ✖/exit-1 and names the unit; with the failure + # cleared it reports healthy/exit-0. Minimal node (just the + # package) — the disk/flake/snapper checks self-skip in a VM. + doctor = pkgs.testers.runNixOSTest { + name = "nomarchy-doctor"; + nodes.machine = { ... }: { + environment.systemPackages = [ pkgs.nomarchy-doctor ]; + systemd.services.doomed = { + description = "deliberately failing unit (doctor test)"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.coreutils}/bin/false"; + }; + wantedBy = [ "multi-user.target" ]; + }; + }; + testScript = '' + machine.wait_for_unit("multi-user.target") + machine.wait_until_succeeds("systemctl is-failed doomed.service") + out = machine.fail("nomarchy-doctor 2>&1") + assert "doomed.service" in out, f"doctor did not name the failed unit:\n{out}" + assert "journalctl" in out, f"doctor did not print a fix:\n{out}" + machine.succeed("systemctl reset-failed") + machine.succeed("nomarchy-doctor") + ''; + }; + # Config-level VM assertions for the nomarchy.hardware.* toggles — # what they SET (kernel cmdline, fprintd, PAM). Real hardware # behaviour (firmware/driver/device) needs bare metal and is out of diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index 6190377..c5bb0aa 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -434,6 +434,12 @@ ${themeRows} || { notify-send "Snapshots" "Snapshot tools unavailable (nomarchy.system.snapper off?)."; exit 0; } exec ${cfg.terminal} -e sudo nomarchy-snapshots ;; + doctor) + # Read-only health sheet in a terminal; exit code doesn't matter + # here — the sheet itself is the product. + exec ${cfg.terminal} -e sh -c "nomarchy-doctor + printf '\nEnter to close.'; read -r _" ;; + rollback) # Undo, one menu away (informative + rock-stable). Desktop = # Home Manager generations: theme/config history is already one @@ -528,6 +534,7 @@ ${themeRows} fi command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift row "Rollback" edit-undo + command -v nomarchy-doctor >/dev/null 2>&1 && row "Doctor" utilities-system-monitor if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then row "Power profile" preferences-system-power fi @@ -547,6 +554,7 @@ ${themeRows} *"Auto-commit"*) exec "$0" autocommit ;; *Snapshots*) exec "$0" snapshot ;; *Rollback*) exec "$0" rollback ;; + *Doctor*) exec "$0" doctor ;; *"Power profile"*) exec "$0" power-profile ;; esac ;; diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index 5897c10..41b729c 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -293,6 +293,7 @@ in # ── Essential packages ─────────────────────────────────────────── environment.systemPackages = with pkgs; [ nomarchy-theme-sync # provided by overlays.default + nomarchy-doctor # read-only health check (System › Doctor) # Friendly wrappers for the two rebuild paths (README §3). Run as # your user: `nix flake update` must NOT run as root (libgit2 diff --git a/pkgs/nomarchy-doctor/default.nix b/pkgs/nomarchy-doctor/default.nix new file mode 100644 index 0000000..e44d938 --- /dev/null +++ b/pkgs/nomarchy-doctor/default.nix @@ -0,0 +1,10 @@ +# One-shot read-only health check (System › Doctor / `nomarchy-doctor`). +# A package (not an inline script) so the VM check can exercise it on a +# minimal node without pulling in the whole distro module. +{ writeShellApplication, coreutils, gawk, git, gnugrep, jq, systemd }: + +writeShellApplication { + name = "nomarchy-doctor"; + runtimeInputs = [ coreutils gawk git gnugrep jq systemd ]; + text = builtins.readFile ./nomarchy-doctor.sh; +} diff --git a/pkgs/nomarchy-doctor/nomarchy-doctor.sh b/pkgs/nomarchy-doctor/nomarchy-doctor.sh new file mode 100644 index 0000000..c702ff2 --- /dev/null +++ b/pkgs/nomarchy-doctor/nomarchy-doctor.sh @@ -0,0 +1,138 @@ +# nomarchy-doctor — one-shot, READ-ONLY health check: the things that +# actually break user machines, as a pass/fail sheet where every +# failure prints the one command that fixes it. It never changes +# anything itself. Exit 0 = no failures (warnings allowed), 1 = at +# least one ✖. + +flake="${NOMARCHY_PATH:-$HOME/.nomarchy}" +fails=0 +warns=0 + +grn=$'\033[32m'; red=$'\033[31m'; ylw=$'\033[33m'; dim=$'\033[2m'; rst=$'\033[0m' +ok() { printf ' %b✔%b %s\n' "$grn" "$rst" "$1"; } +bad() { printf ' %b✖%b %s\n' "$red" "$rst" "$1" + printf ' %bfix: %s%b\n' "$dim" "$2" "$rst" + fails=$((fails + 1)); } +warn() { printf ' %b●%b %s\n' "$ylw" "$rst" "$1" + if [ $# -gt 1 ]; then printf ' %b%s%b\n' "$dim" "$2" "$rst"; fi + warns=$((warns + 1)); } +skip() { printf ' %b– %s%b\n' "$dim" "$1" "$rst"; } + +printf 'nomarchy-doctor — %s\n\n' "$(date '+%Y-%m-%d %H:%M')" + +# ── systemd units ──────────────────────────────────────────────────── +mapfile -t sysfailed < <(systemctl --failed --no-legend --plain 2>/dev/null | awk '{print $1}') +if [ "${#sysfailed[@]}" -eq 0 ]; then + ok "no failed system units" +else + bad "failed system unit(s): ${sysfailed[*]}" \ + "journalctl -b -u (read why; sys-rebuild after fixing)" +fi + +# A user bus only exists inside a session (not over bare SSH/root). +if [ -n "$(systemctl --user is-system-running 2>/dev/null || true)" ]; then + mapfile -t usrfailed < <(systemctl --user --failed --no-legend --plain 2>/dev/null | awk '{print $1}') + if [ "${#usrfailed[@]}" -eq 0 ]; then + ok "no failed user units" + else + bad "failed user unit(s): ${usrfailed[*]}" \ + "journalctl --user -b -u " + fi +else + skip "user units (no user session bus here)" +fi + +# ── disk space ─────────────────────────────────────────────────────── +# Only real on-disk filesystems; skips the tmpfs/9p/overlay mounts of +# live systems and test VMs (where usage numbers mean nothing). +seen="" +for mp in / /boot /nix; do + [ -d "$mp" ] || continue + line=$(df -PT "$mp" 2>/dev/null | awk 'NR==2 {gsub("%","",$6); print $1, $2, $6, $5}') + [ -n "$line" ] || continue + read -r dev fstype use availkb <<<"$line" + case "$fstype" in + ext2|ext3|ext4|btrfs|xfs|vfat|f2fs|zfs) ;; + *) continue ;; + esac + case " $seen " in *" $dev "*) continue ;; esac + seen="$seen $dev" + avail=$(numfmt --to=iec $((availkb * 1024))) + if [ "$use" -ge 90 ]; then + bad "$mp is ${use}% full (${avail} free)" \ + "sudo nix-collect-garbage --delete-older-than 14d (then sys-rebuild to prune old boot entries)" + else + ok "$mp has space (${use}% used, ${avail} free)" + fi +done + +# ── the flake + state file ─────────────────────────────────────────── +if [ -f "$flake/theme-state.json" ]; then + if jq empty "$flake/theme-state.json" 2>/dev/null; then + ok "theme-state.json parses" + else + bad "theme-state.json is not valid JSON (rebuilds will fail)" \ + "jq . $flake/theme-state.json (shows the bad spot; fix it, or re-apply a theme)" + fi + if git -C "$flake" ls-files --error-unmatch theme-state.json >/dev/null 2>&1; then + ok "theme-state.json is git-tracked" + else + bad "theme-state.json is NOT git-tracked (flake evaluation can't see it)" \ + "git -C $flake add theme-state.json" + fi +else + skip "theme-state.json (no flake checkout at $flake)" +fi + +if [ -d "$flake/.git" ]; then + dirty=$(git -C "$flake" status --porcelain 2>/dev/null | wc -l) + if [ "$dirty" -gt 0 ]; then + warn "$dirty uncommitted change(s) in $flake (normal — theme writes land there)" \ + "commit when happy: git -C $flake add -A && git -C $flake commit -m settings" + else + ok "flake checkout is clean" + fi + behind=$(git -C "$flake" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0) + if [ "$behind" -gt 0 ]; then + warn "flake is $behind commit(s) behind its upstream" "git -C $flake pull, then sys-rebuild + home-update" + fi +fi + +# ── generation age ─────────────────────────────────────────────────── +# The profile SYMLINK's own mtime is the generation's creation time +# (store paths themselves are all epoch-1). +link=$(readlink /nix/var/nix/profiles/system 2>/dev/null || true) +if [ -n "$link" ] && [ -e "/nix/var/nix/profiles/$link" ]; then + ts=$(stat -c %Y "/nix/var/nix/profiles/$link" 2>/dev/null || echo 0) + days=$(( ($(date +%s) - ts) / 86400 )) + if [ "$days" -gt 30 ]; then + warn "last system rebuild was $days days ago" \ + "sys-update when convenient (security fixes arrive with input bumps)" + else + ok "system generation is $days day(s) old" + fi +else + skip "system generation age (no system profile)" +fi + +# ── snapper timeline (when enabled) ────────────────────────────────── +if [ -n "$(systemctl list-unit-files snapper-timeline.timer --no-legend --plain 2>/dev/null)" ]; then + if systemctl is-active --quiet snapper-timeline.timer; then + ok "snapper timeline snapshots are running" + else + bad "snapper is enabled but the timeline timer is not active" \ + "systemctl status snapper-timeline.timer (then journalctl -u snapper-timeline.service)" + fi +else + skip "snapper (not enabled on this machine)" +fi + +# ── verdict ────────────────────────────────────────────────────────── +echo +if [ "$fails" -eq 0 ]; then + printf '%b✔ healthy%b — %d warning(s)\n' "$grn" "$rst" "$warns" + exit 0 +else + printf '%b✖ %d problem(s)%b, %d warning(s) — each ✖ above shows its fix\n' "$red" "$fails" "$rst" "$warns" + exit 1 +fi