aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-15-updates.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-10-15-updates.md')
-rw-r--r--docs/superpowers/plans/2026-10-15-updates.md1196
1 files changed, 1196 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-15-updates.md b/docs/superpowers/plans/2026-10-15-updates.md
new file mode 100644
index 0000000..c0c5c48
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-15-updates.md
@@ -0,0 +1,1196 @@
+# Updates Module Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A drawer tile and page that sort every pending update into the next action: Slackware upgrade/new/removed, upgrade here, awaiting build, upstream newer, app updates.
+
+**Architecture:** One stdlib Python script, `desktop/modules/updates/updatesctl`, reads every source and prints one JSON document with the sections already decided. `UpdatesModule.qml` runs it while the drawer is open and `UpdatesPage.qml` only draws. Spec: `docs/superpowers/specs/2026-10-15-updates-design.md`.
+
+**Tech Stack:** `/usr/bin/python3` (stdlib only), Quickshell 0.3.1 QML.
+
+**Rules that bite here (from AGENTS.md):**
+- Use `/usr/bin/python3`, never `python3` (a pyenv shim).
+- Never start `qs` from a tool call, never `pkill -f`. QML hot-reloads on save; ask the user to look.
+- No home paths in committed files: write `~` in docs; code builds paths from `$HOME`.
+- An injected id must not share the property's name (`up: mod`, never `mod: mod`).
+- Every source file carries the GPLv2 header. Commits are GPG-signed and carry the reasoning.
+
+---
+
+## File map
+
+- Create `desktop/modules/updates/updatesctl`: the script (sources, version order, sections, JSON).
+- Create `desktop/modules/updates/test_updatesctl.py`: fixture tree + asserts, no framework.
+- Create `desktop/modules/updates/UpdatesModule.qml`: process, freshness, tile.
+- Create `desktop/modules/updates/UpdatesPage.qml`: the sections.
+- Create `desktop/modules/updates/README.md`.
+- Modify `desktop/shell.qml`: import and register the module after `ClipboardModule`.
+- Modify `AGENTS.md`: add "updates" to the drawer's list.
+
+---
+
+### Task 1: Version order
+
+**Files:**
+- Create: `desktop/modules/updates/updatesctl`
+- Create: `desktop/modules/updates/test_updatesctl.py`
+
+- [ ] **Step 1: Write the failing test**
+
+`desktop/modules/updates/test_updatesctl.py`:
+
+```python
+#!/usr/bin/python3
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+"""Run: /usr/bin/python3 desktop/modules/updates/test_updatesctl.py
+
+Imports updatesctl for vercmp, then drives it as a CLI against a fixture tree
+under a temp dir (UPDATESCTL_ROOT for system paths, HOME for the user's),
+never the live system. Plain asserts, no framework.
+"""
+
+import importlib.machinery
+import importlib.util
+from pathlib import Path
+
+SCRIPT = Path(__file__).resolve().parent / "updatesctl"
+
+loader = importlib.machinery.SourceFileLoader("updatesctl", str(SCRIPT))
+spec = importlib.util.spec_from_loader("updatesctl", loader)
+u = importlib.util.module_from_spec(spec)
+loader.exec_module(u)
+
+# --- vercmp ---
+assert u.vercmp("1.14.10", "1.14.9") > 0
+assert u.vercmp("2.1.278", "2.1.267") > 0
+assert u.vercmp("2026_10_02", "2026_09_12") > 0
+assert u.vercmp("v1.2", "1.2") == 0
+assert u.vercmp("1.0", "1.0") == 0
+# A pre-release is older than the release, a suffix is never behind.
+assert u.vercmp("0.12.3_beta.4", "0.12.3") < 0
+assert u.vercmp("9.018.00_6.18.41", "9.018.00") > 0
+assert u.vercmp("3.1-659215d", "3.1") > 0
+assert u.vercmp("1.0.1", "1.0rc1") > 0
+
+print("ok")
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py`
+Expected: FAIL, `FileNotFoundError` for `updatesctl`.
+
+- [ ] **Step 3: Write the script skeleton with vercmp**
+
+`desktop/modules/updates/updatesctl`:
+
+```python
+#!/usr/bin/python3
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+"""updatesctl - what to do next to bring this machine up to date.
+
+Prints one JSON document for the drawer's updates module, with every pending
+update already sorted into the action it needs. The sections and the reasons
+for them are in docs/superpowers/specs/2026-10-15-updates-design.md.
+
+ updatesctl from the last nvchecker results
+ updatesctl --refresh run nvchecker first
+
+UPDATESCTL_ROOT prefixes every system path and HOME roots the user's, so the
+test can point both at a fixture tree.
+"""
+
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from functools import cmp_to_key
+from pathlib import Path
+
+ROOT = os.environ.get("UPDATESCTL_ROOT", "")
+HOME = Path(os.environ["HOME"])
+NFS_TIMEOUT = float(os.environ.get("UPDATESCTL_NFS_TIMEOUT", "5"))
+
+
+def sysp(p):
+ return Path(ROOT + p)
+
+
+def vercmp(a, b):
+ """Version order: numeric chunks as numbers, numbers after letters.
+
+ When one version runs out, the longer one is newer unless its next chunk
+ is alphabetic, which makes 0.12.3_beta.4 older than 0.12.3 and a kernel
+ suffix or git hash (9.018.00_6.18.41) newer than its base, never behind.
+ """
+ def chunks(v):
+ v = v.lower()
+ return re.findall(r"\d+|[a-z]+", v[1:] if v.startswith("v") else v)
+
+ x, y = chunks(a), chunks(b)
+ for p, q in zip(x, y):
+ if p == q:
+ continue
+ if p.isdigit() and q.isdigit():
+ return (int(p) > int(q)) - (int(p) < int(q))
+ if p.isdigit() != q.isdigit():
+ return 1 if p.isdigit() else -1
+ return (p > q) - (p < q)
+ if len(x) == len(y):
+ return 0
+ longer, sign = (x, 1) if len(x) > len(y) else (y, -1)
+ return sign if longer[min(len(x), len(y))].isdigit() else -sign
+
+
+def main():
+ pass
+
+
+if __name__ == "__main__":
+ main()
+```
+
+Then: `chmod +x desktop/modules/updates/updatesctl`
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py`
+Expected: `ok`
+
+- [ ] **Step 5: Show the test can fail.** Temporarily change `return sign if longer[...]` to `return sign`, rerun, and expect an `AssertionError` on the beta line. Revert, rerun, expect `ok`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/updates/updatesctl desktop/modules/updates/test_updatesctl.py
+git commit -S -F - <<'EOF'
+feat(updates): version order for updatesctl
+
+nvtable compares versions for equality, which cannot tell an installed
+2.1.278 from an SBo recipe of 2.1.267 that is older than it. vercmp
+orders numeric chunks as numbers and treats a trailing alphabetic chunk
+as a pre-release, so a kernel suffix or git hash orders as newer than
+its base and never reads as behind.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 2: Tracked packages into sections
+
+**Files:**
+- Modify: `desktop/modules/updates/updatesctl`
+- Modify: `desktop/modules/updates/test_updatesctl.py`
+
+- [ ] **Step 1: Write the failing test.** Replace the final `print("ok")` in `test_updatesctl.py` with the fixture tree and the section asserts below, and add `import json, os, shutil, subprocess, tempfile` to the imports at the top.
+
+```python
+# --- fixture tree ---
+tmp = Path(tempfile.mkdtemp(prefix="updatesctl-test-"))
+home = tmp / "home"
+
+
+def w(path, text=""):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+
+
+def info(repo_rel, version):
+ w(home / "Programming/GIT/slackbuilds-superrepo" / repo_rel,
+ f'PRGNAM="x"\nVERSION="{version}"\n')
+
+
+def sbo(rel, version):
+ w(tmp / "var/lib/sbopkg/SBo-git" / rel, f'VERSION="{version}"\n')
+
+
+def pkgtxt(entries):
+ return "".join(f"PACKAGE NAME: {full}.txz\nPACKAGE LOCATION: ./{loc}\n\n"
+ for loc, full in entries)
+
+
+installed = [
+ "kitty-0.49.1-x86_64-1_danix", # built 0.49.2 -> upgrade
+ "gamescope-3.16.30-x86_64-1_danix", # recipe bumped -> build
+ "discord-1.0.159-x86_64-1_danix", # recipe behind -> upstream
+ "claude-code-2.1.278-x86_64-1_SBo", # ahead of SBo -> upstream
+ "nessus-10.12.4-x86_64-1_SBo", # SBo current -> build
+ "neovim-0.12.6-x86_64-1_SBo", # current -> nothing
+ "r8125-9.018.00_6.18.41-x86_64-1_danix", # suffix -> nothing
+ "solvespace-3.1-659215d-x86_64-1_danix", # dash version -> nothing
+ "glibc-2.44-x86_64-3", # mirror has -4 -> slackware upgrade
+ "bash-5.3-x86_64-1", # testing/ newer, blacklisted -> nothing
+ "dhcp-4.4.3-x86_64-1", # removed upstream -> slackware removed
+ "foo-1.0-x86_64-1_SBo", # blacklisted build -> nothing
+]
+for f in installed:
+ w(tmp / "var/log/packages" / f)
+
+w(home / ".config/nvchecker/new_ver.json", json.dumps({"version": 2, "data": {
+ "kitty": {"version": "0.49.2"},
+ "gamescope": {"version": "3.16.31"},
+ "discord": {"version": "1.0.162"},
+ "claude-code": {"version": "2.1.300"},
+ "nessus": {"version": "10.12.5"},
+ "neovim": {"version": "0.12.6"},
+ "r8125": {"version": "9.018.00"},
+ "solvespace": {"version": "3.1"},
+ "exploitdb": {"version": "2026_10_02"}, # not installed, my recipe behind
+ "obsidian": {"version": "1.14.4"}, # not installed, SBo behind: hidden
+ "Typora": {"version": "1.14.10"}, # AppImage behind
+ "GIMP": {"version": "3.0.6"}, # flatpak behind
+}}))
+
+info("my-slackbuilds/kitty-bin/kitty-bin.info", "0.49.2")
+info("my-slackbuilds/gamescope/gamescope.info", "3.16.31")
+info("my-slackbuilds/network/discord/discord.info", "1.0.161")
+info("my-slackbuilds/r8125/r8125.info", "9.018.00")
+info("my-slackbuilds/solvespace/solvespace.info", "3.1")
+info("sbo-slackbuilds/system/exploitdb/exploitdb.info", "2026_09_12")
+sbo("development/claude-code-bin/claude-code-bin.info", "2.1.267")
+sbo("network/nessus/nessus.info", "10.12.5")
+sbo("development/neovim/neovim.info", "0.12.4")
+sbo("office/obsidian/obsidian.info", "1.13.7")
+
+w(home / ".local/state/appimage-updater/installed.json",
+ json.dumps({"Typora": {"version": "1.14.9"}}))
+w(tmp / "bin/flatpak", "#!/bin/sh\nprintf 'org.gimp.GIMP\\t3.0.4\\n'\n")
+(tmp / "bin/flatpak").chmod(0o755)
+
+w(tmp / "etc/slackpkg/slackpkgplus.conf", "MIRRORPLUS['danix']=dir://repo/\n")
+w(tmp / "repo/PACKAGES.TXT", pkgtxt([
+ ("devel/kitty", "kitty-0.49.2-x86_64-1_danix"),
+ ("games/gamescope", "gamescope-3.16.30-x86_64-1_danix"),
+]))
+
+w(tmp / "etc/slackpkg/mirrors", "# a comment\nfile://mnt/mirror/\n")
+w(tmp / "etc/slackpkg/blacklist", '"testing/"\n[0-9]+_SBo\n')
+w(tmp / "mnt/mirror/PACKAGES.TXT", pkgtxt([
+ ("slackware64/l", "glibc-2.44-x86_64-4"),
+ ("slackware64/a", "bash-5.3-x86_64-1"),
+ ("testing/packages", "bash-5.4-x86_64-1"),
+ ("slackware64/a", "foo-1.1-x86_64-1"),
+ ("slackware64/l", "libtatsu-1.0.5-x86_64-1"),
+]))
+w(tmp / "mnt/mirror/ChangeLog.txt", """\
+Wed Oct 14 21:48:34 UTC 2026
+l/glibc-2.44-x86_64-4.txz: Rebuilt.
+l/libtatsu-1.0.5-x86_64-1.txz: Upgraded.
+n/dhcp-4.4.3-x86_64-1.txz: Removed.
++--------------------------+
+Thu Oct 1 10:00:00 UTC 2026
+x/oldthing-1.0-x86_64-1.txz: Removed.
+l/libtatsu-1.0.4-x86_64-1.txz: Added.
++--------------------------+
+Tue Sep 1 10:00:00 UTC 2026
+x/oldthing-1.0-x86_64-1.txz: Added.
+""")
+
+
+def run(*args, timeout="5"):
+ env = {"HOME": str(home), "UPDATESCTL_ROOT": str(tmp),
+ "UPDATESCTL_NFS_TIMEOUT": timeout,
+ "PATH": f"{tmp}/bin:/usr/bin:/bin"}
+ r = subprocess.run(["/usr/bin/python3", str(SCRIPT), *args],
+ capture_output=True, text=True, env=env, timeout=30)
+ assert r.returncode == 0, r.stderr
+ return json.loads(r.stdout)
+
+
+def rows(section):
+ return {x["name"]: (x["repo"], x["from"], x["to"]) for x in section}
+
+
+d = run()
+assert d["errors"] == {}, d["errors"]
+assert d["checked"] > 0
+assert rows(d["upgrade"]) == {"kitty": ("personal", "0.49.1", "0.49.2")}, d["upgrade"]
+assert rows(d["build"]) == {
+ "gamescope": ("personal", "3.16.30", "3.16.31"),
+ "nessus": ("SBo", "10.12.4", "10.12.5"),
+}, d["build"]
+assert rows(d["upstream"]) == {
+ "discord": ("personal", "1.0.161", "1.0.162"),
+ "claude-code": ("SBo", "2.1.278", "2.1.300"),
+ "exploitdb": ("my-sbo", "2026_09_12", "2026_10_02"),
+}, d["upstream"]
+assert rows(d["apps"]) == {
+ "Typora": ("AppImage", "1.14.9", "1.14.10"),
+ "GIMP": ("flatpak", "3.0.4", "3.0.6"),
+}, d["apps"]
+
+shutil.rmtree(tmp)
+print("ok")
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py`
+Expected: FAIL, `json.decoder.JSONDecodeError` (main prints nothing).
+
+- [ ] **Step 3: Implement tracked packages.** In `updatesctl`, add after `sysp()`:
+
+```python
+NVCONF = HOME / ".config/nvchecker/nvchecker.toml"
+NEWVER = HOME / ".config/nvchecker/new_ver.json"
+SUPER = HOME / "Programming/GIT/slackbuilds-superrepo"
+SBO = "/var/lib/sbopkg/SBo-git"
+AI_STATE = (Path(os.environ.get("XDG_STATE_HOME") or HOME / ".local/state")
+ / "appimage-updater/installed.json")
+
+# Repo directory -> the label nvtable shows for it.
+REPOS = [("sbo-slackbuilds", "my-sbo"), ("my-slackbuilds", "personal"),
+ ("Slackware-Pentesting-Suite", "pentesting")]
+
+# From nvtable. Stanza name -> installed package name(s), where they differ.
+ALIAS = {
+ "kvantum": ["kvantum-qt5", "kvantum-qt6"],
+ "metasploit-framework": ["metasploit-framework-bin"],
+ "Typora": ["typora-bin"],
+}
+
+# From nvtable. Stanza name -> SlackBuild directory, where they differ.
+REPO_ALIAS = {
+ "kitty": ["kitty-bin"],
+ "claude-code": ["claude-code-bin"],
+ "kvantum": ["kvantum-qt6"],
+ "metasploit-framework": ["metasploit-framework-bin"],
+ "Typora": ["typora-bin"],
+}
+
+
+def read(path):
+ try:
+ return Path(path).read_text(errors="replace")
+ except OSError:
+ return ""
+
+
+def nfs_read(path):
+ """A file on the NFS share, or None. The mount is hard, so a read during
+ a NAS outage blocks: it runs in a child that the timeout kills."""
+ try:
+ r = subprocess.run(["cat", str(path)], capture_output=True,
+ timeout=NFS_TIMEOUT)
+ except subprocess.TimeoutExpired:
+ return None
+ return r.stdout.decode(errors="replace") if r.returncode == 0 else None
+
+
+def split(full):
+ """name-version-arch-build. Right for Slackware's own packages, whose
+ versions never contain a dash; tracked ones go through version_in()."""
+ return full.rsplit("-", 3)
+
+
+def version_in(fulls, names):
+ """The version of the first of names found among package file names.
+
+ Anchored on "<name>-" followed by a digit, so a version containing a
+ dash (solvespace-3.1-659215d) survives and a longer sibling
+ (kvantum-qt5 when asked for kvantum) is never taken for a match."""
+ for want in names:
+ for full in fulls:
+ stem = full.rsplit("-", 2)[0]
+ if stem.lower().startswith(want.lower() + "-"):
+ rest = stem[len(want) + 1:]
+ if rest[:1].isdigit():
+ return rest
+ return ""
+
+
+def packages_txt(text):
+ """PACKAGES.TXT as (location, full name) pairs, location without ./"""
+ out, name = [], None
+ for line in text.splitlines():
+ if line.startswith("PACKAGE NAME:"):
+ name = re.sub(r"\.t[gx]z$", "", line.split(":", 1)[1].strip())
+ elif line.startswith("PACKAGE LOCATION:") and name:
+ out.append((line.split(":", 1)[1].strip().lstrip("./"), name))
+ name = None
+ return out
+
+
+def local_dir(url):
+ m = re.match(r"(?:file|dir)://(.*)", url.strip())
+ return sysp("/" + m.group(1).lstrip("/")) if m else None
+
+
+def repo_dir():
+ m = re.search(r"^MIRRORPLUS\['danix'\]=(\S+)",
+ read(sysp("/etc/slackpkg/slackpkgplus.conf")), re.M)
+ return local_dir(m.group(1)) if m else None
+
+
+def info_version(path):
+ m = re.search(r'^VERSION="(.*)"$', read(path), re.M)
+ return m.group(1) if m else ""
+
+
+def recipes():
+ """SlackBuild directory -> (version, label) over the user's repos."""
+ out = {}
+ for d, label in REPOS:
+ base = SUPER / d
+ for inf in sorted(list(base.glob("*/*.info")) + list(base.glob("*/*/*.info"))):
+ v = info_version(inf)
+ if v:
+ out.setdefault(inf.stem, (v, label))
+ return out
+
+
+def recipe_of(name, mine):
+ """(version, label) of the recipe: the user's first, then the SBo clone."""
+ cands = [name] + REPO_ALIAS.get(name, []) + ALIAS.get(name, [])
+ for c in cands:
+ if c in mine:
+ return mine[c]
+ for c in cands:
+ for inf in sysp(SBO).glob(f"*/{c}/{c}.info"):
+ v = info_version(inf)
+ if v:
+ return v, "SBo"
+ return "", ""
+
+
+def apps():
+ """Lowercased app key -> (version, source) for AppImages and flatpaks."""
+ out = {}
+ try:
+ for k, v in json.loads(read(AI_STATE) or "{}").items():
+ out[k.lower()] = (v.get("version", ""), "AppImage")
+ except (ValueError, AttributeError):
+ pass
+ if shutil.which("flatpak"):
+ r = subprocess.run(["flatpak", "list", "--app", "--columns=application,version"],
+ capture_output=True, text=True)
+ for line in r.stdout.splitlines():
+ app, _, v = line.partition("\t")
+ if app:
+ out[app.rsplit(".", 1)[-1].lower()] = (v, "flatpak")
+ return out
+
+
+def row(name, repo, frm, to):
+ return {"name": name, "repo": repo, "from": frm, "to": to}
+
+
+def tracked(installed, errors):
+ out = {k: [] for k in ("upgrade", "build", "upstream", "apps")}
+ try:
+ data = json.loads(NEWVER.read_text())
+ except (OSError, ValueError):
+ errors["nvchecker"] = "no results"
+ return out
+ data = data.get("data", data)
+
+ rd = repo_dir()
+ pk = nfs_read(rd / "PACKAGES.TXT") if rd else None
+ if pk is None:
+ errors["repo"] = "unreachable"
+ built = [full for _, full in packages_txt(pk or "")]
+ mine, app = recipes(), apps()
+
+ for name in sorted(data, key=str.lower):
+ v = data[name]
+ up = v.get("version", "") if isinstance(v, dict) else str(v)
+ names = ALIAS.get(name, [name])
+
+ i = version_in(installed, names)
+ if not i:
+ hit = next((app[n.lower()] for n in [name] + names if n.lower() in app), None)
+ if hit:
+ if vercmp(hit[0], up) < 0:
+ out["apps"].append(row(name, hit[1], hit[0], up))
+ continue
+
+ r, label = recipe_of(name, mine)
+ b = version_in(built, [name] + names + REPO_ALIAS.get(name, []))
+ label = label or "--"
+
+ if i and b and vercmp(b, i) > 0:
+ out["upgrade"].append(row(name, label, i, b))
+ elif i and r and vercmp(r, up) >= 0 and (not b or vercmp(b, up) < 0):
+ if vercmp(i, up) < 0:
+ out["build"].append(row(name, label, b or i, up))
+ elif i or (r and label != "SBo"):
+ # ponytail: base is the newest of recipe, build and install; an
+ # installed version ahead of every recipe still counts as behind
+ # upstream when upstream moved past it.
+ base = max((x for x in (r, b, i) if x),
+ key=cmp_to_key(vercmp), default="")
+ if base and vercmp(up, base) > 0:
+ out["upstream"].append(row(name, label, base, up))
+ return out
+```
+
+Replace `main()`:
+
+```python
+def main():
+ errors = {}
+ if "--refresh" in sys.argv[1:]:
+ # Exit status is not checked: nvchecker exits 0 with failed stanzas
+ # logged, and a partial run still rewrites new_ver.json.
+ try:
+ subprocess.run(["nvchecker", "-c", str(NVCONF)],
+ capture_output=True, timeout=600)
+ except (OSError, subprocess.SubprocessError):
+ errors["nvchecker"] = "refresh failed"
+ try:
+ installed = os.listdir(sysp("/var/log/packages"))
+ except OSError:
+ installed = []
+ doc = {"checked": int(NEWVER.stat().st_mtime) if NEWVER.exists() else 0}
+ doc.update(tracked(installed, errors))
+ doc["errors"] = errors
+ json.dump(doc, sys.stdout)
+ print()
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py`
+Expected: `ok`. If an assert fails, it prints the actual section. Fix the code, not the fixture, unless the fixture contradicts the spec.
+
+- [ ] **Step 5: Show the test can fail.** Temporarily remove `and (not b or vercmp(b, up) < 0)`, rerun, and expect the `build` assert to fail. Revert, expect `ok`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/updates/updatesctl desktop/modules/updates/test_updatesctl.py
+git commit -S -F - <<'EOF'
+feat(updates): sort tracked packages into the next action
+
+Each nvchecker stanza lands in one section: upgrade here when the binary
+repo's PACKAGES.TXT holds a newer build than installed, awaiting build
+when the recipe (the user's or SBo's) has caught up but the build has
+not, upstream newer when the recipe lags, app updates for AppImages and
+flatpaks. Built comes from PACKAGES.TXT rather than the .info, which is
+why gamescope and kitty no longer read as installable: the repo still
+held the old build. The repo is on a hard NFS mount, so it is read by a
+child the timeout can kill.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 3: Slackware upgrade, new, removed
+
+**Files:**
+- Modify: `desktop/modules/updates/updatesctl`
+- Modify: `desktop/modules/updates/test_updatesctl.py`
+
+- [ ] **Step 1: Write the failing test.** In `test_updatesctl.py`, after the `apps` assert and before `shutil.rmtree(tmp)`, add:
+
+```python
+sw = d["slackware"]
+assert sw["upgrade"] == [{"name": "glibc", "from": "2.44-3", "to": "2.44-4"}], sw
+assert sw["new"] == [{"name": "libtatsu", "version": "1.0.5-1"}], sw
+assert sw["removed"] == [{"name": "dhcp", "date": "2026-10-14"}], sw
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py`
+Expected: FAIL, `KeyError: 'slackware'`.
+
+- [ ] **Step 3: Implement.** In `updatesctl`, add before `main()`:
+
+```python
+def blacklist():
+ """slackpkg's blacklist as regexes, its surrounding quotes dropped.
+
+ ponytail: a pattern blocks when it occurs anywhere in location/full
+ name, so a bare "dhcp" would also block "dhcpcd"; slackpkg itself is
+ stricter. Fine for a list of build tags and directories."""
+ out = []
+ for line in read(sysp("/etc/slackpkg/blacklist")).splitlines():
+ p = line.strip().strip('"')
+ if p and not p.startswith("#"):
+ out.append(re.compile(p))
+ return out
+
+
+def blocked(bl, path):
+ return any(p.search(path) for p in bl)
+
+
+DATE = re.compile(r"^[A-Z][a-z]{2} [A-Z][a-z]{2} +\d+ \d\d:\d\d:\d\d UTC \d{4}$")
+CLPKG = re.compile(r"^(\S+?)\.t[gx]z:\s+(Added|Removed)\.")
+
+
+def changelog(text):
+ """Package name -> (Added|Removed, date) from its newest such line.
+
+ The ChangeLog is newest first, so the first line seen wins. Other
+ actions are skipped: a package added and later upgraded is still new."""
+ seen, date = {}, ""
+ for line in text.splitlines():
+ if DATE.match(line):
+ date = time.strftime("%Y-%m-%d",
+ time.strptime(line, "%a %b %d %H:%M:%S UTC %Y"))
+ continue
+ m = CLPKG.match(line)
+ if m:
+ name = split(m.group(1).rsplit("/", 1)[-1])[0]
+ seen.setdefault(name, (m.group(2), date))
+ return seen
+
+
+def mirror_dir():
+ for line in read(sysp("/etc/slackpkg/mirrors")).splitlines():
+ line = line.strip()
+ if line and not line.startswith("#"):
+ return local_dir(line)
+ return None
+
+
+def verbuild(full):
+ _, v, _, b = split(full)
+ return f"{v}-{b}"
+
+
+def slackware(installed, errors):
+ out = {"upgrade": [], "new": [], "removed": []}
+ d = mirror_dir()
+ if d is None:
+ errors["mirror"] = "not a local mirror"
+ return out
+ pk, cl = nfs_read(d / "PACKAGES.TXT"), nfs_read(d / "ChangeLog.txt")
+ if pk is None or cl is None:
+ errors["mirror"] = "unreachable"
+ return out
+
+ bl = blacklist()
+ mirror = {}
+ for loc, full in packages_txt(pk):
+ if not blocked(bl, f"{loc}/{full}"):
+ mirror.setdefault(split(full)[0], full)
+ every = {split(f)[0] for f in installed if f.count("-") >= 3}
+ mine = {split(f)[0]: f for f in installed
+ if f.count("-") >= 3 and not blocked(bl, f)}
+
+ for name, full in sorted(mine.items()):
+ if name in mirror and mirror[name] != full:
+ out["upgrade"].append({"name": name, "from": verbuild(full),
+ "to": verbuild(mirror[name])})
+ for name, (act, date) in sorted(changelog(cl).items()):
+ if act == "Added" and name in mirror and name not in every:
+ out["new"].append({"name": name, "version": verbuild(mirror[name])})
+ elif act == "Removed" and name in mine and name not in mirror:
+ out["removed"].append({"name": name, "date": date})
+ return out
+```
+
+In `main()`, change the `doc = ...` line to:
+
+```python
+ doc = {"checked": int(NEWVER.stat().st_mtime) if NEWVER.exists() else 0,
+ "slackware": slackware(installed, errors)}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py`
+Expected: `ok`. That covers the blacklisted `foo` (`_SBo`) and the `testing/` bash staying out, `oldthing` (Added, then Removed) appearing nowhere, and libtatsu (Added, then Upgraded) counting as new.
+
+- [ ] **Step 5: Show the test can fail.** Temporarily change `if not blocked(bl, f"{loc}/{full}")` to `if True`, rerun, and expect the `upgrade` assert to fail on bash 5.4. Revert, expect `ok`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add desktop/modules/updates/updatesctl desktop/modules/updates/test_updatesctl.py
+git commit -S -F - <<'EOF'
+feat(updates): Slackware upgrades, new and removed packages
+
+Compared mirror against installed, as upgrade-all does, not against
+slackpkg's local ChangeLog: `slackpkg update` replaces that without
+installing anything, so a diff against it reads clean after an update
+that was never applied. New and removed come from the mirror's
+ChangeLog, the newest Added or Removed line deciding, so a package
+added and later upgraded still counts as new. Blacklisted build tags
+and directories are skipped, as slackpkg skips them.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 4: A hung NFS read
+
+**Files:**
+- Modify: `desktop/modules/updates/test_updatesctl.py`
+
+`nfs_read` already has the timeout (Task 2). This task proves it with a source that blocks like a hard mount.
+
+- [ ] **Step 1: Write the test.** Before `shutil.rmtree(tmp)`, add:
+
+```python
+# A fifo with no writer blocks cat at open(), as a hard NFS mount blocks a
+# read while the NAS is down.
+import time as _t
+(tmp / "repo/PACKAGES.TXT").unlink()
+os.mkfifo(tmp / "repo/PACKAGES.TXT")
+t0 = _t.monotonic()
+d = run(timeout="0.5")
+assert _t.monotonic() - t0 < 5, "a hung read stalled the script"
+assert d["errors"] == {"repo": "unreachable"}, d["errors"]
+assert d["upgrade"] == [], d["upgrade"]
+assert d["slackware"]["upgrade"], "the mirror sections must still fill"
+```
+
+- [ ] **Step 2: Run it**
+
+Run: `/usr/bin/python3 desktop/modules/updates/test_updatesctl.py`
+Expected: `ok`, in about a second.
+
+- [ ] **Step 3: Show the test can fail.** Temporarily set `timeout=None` in `nfs_read`, rerun, and expect `subprocess.TimeoutExpired` from the test's own 30s guard. Revert, expect `ok`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add desktop/modules/updates/test_updatesctl.py
+git commit -S -F - <<'EOF'
+test(updates): a hung NFS read reports unreachable
+
+A fifo with no writer blocks cat at open() the way a hard NFS mount
+blocks during a NAS outage. The repo section reports unreachable within
+the timeout and the mirror sections still fill.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 5: Live run against the real machine
+
+No code. This checks the script against what is installed.
+
+- [ ] **Step 1: Run it**
+
+Run: `time /usr/bin/python3 desktop/modules/updates/updatesctl | /usr/bin/python3 -m json.tool | head -80`
+Expected: `errors` is `{}`. kitty and gamescope are under `build` or `upstream`, not `upgrade` (the repo holds 0.49.1 and 3.16.30, the installed versions). `slackware.new` lists emacs, libtatsu and f3, and `slackware.removed` lists dhcp, unless the machine changed since the spec. Note the wall time.
+
+- [ ] **Step 2: Cross-check against nvtable.** Run `nvtable -n -C -E` and check that every row it marks is in exactly one updatesctl section or deliberately hidden (an installed version at or past upstream). Investigate any difference before going on; record the reason if it is intended.
+
+---
+
+### Task 6: Module and tile
+
+**Files:**
+- Create: `desktop/modules/updates/UpdatesModule.qml`
+- Modify: `desktop/shell.qml`
+
+- [ ] **Step 1: Write the module**
+
+`desktop/modules/updates/UpdatesModule.qml`:
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "../.."
+
+// Pending updates, sorted by updatesctl into the next action. Nothing runs
+// while the drawer is closed: the tile's lifetime is the check's, as with
+// KDE Connect. A result older than 30 minutes is followed by a refresh,
+// which runs nvchecker.
+Module {
+ id: mod
+
+ name: "updates"
+ label: "Updates"
+
+ // linux-slackware, present in Inconsolata Nerd Font.
+ icon: "\uf318"
+
+ property var doc: null
+ property bool checking: false
+
+ readonly property int toInstall: doc
+ ? doc.slackware.upgrade.length + doc.slackware.new.length
+ + doc.slackware.removed.length + doc.upgrade.length
+ : 0
+ readonly property int toBuild: doc ? doc.build.length : 0
+
+ active: toInstall > 0
+
+ property bool polling: false
+ onPollingChanged: if (polling) run(false)
+
+ function run(refresh) {
+ if (proc.running && mod.checking) return; // never cut nvchecker short
+ proc.command = ["/usr/bin/python3",
+ `${Quickshell.shellDir}/modules/updates/updatesctl`].concat(refresh ? ["--refresh"] : []);
+ proc.running = false;
+ // After the stop: a killed run can still finish its stream, and
+ // its handler clears `checking`.
+ mod.checking = refresh;
+ proc.running = true;
+ }
+
+ property Process proc: Process {
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const refreshed = mod.checking;
+ mod.checking = false;
+ try { mod.doc = JSON.parse(this.text); } catch (e) { return; }
+ // Only after a cached read: a refresh whose nvchecker failed
+ // leaves `checked` old and would otherwise loop.
+ if (!refreshed && mod.polling && Date.now() / 1000 - mod.doc.checked > 1800)
+ mod.run(true);
+ }
+ }
+ }
+
+ tileContent: Component {
+ Text {
+ Component.onCompleted: mod.polling = true
+ Component.onDestruction: mod.polling = false
+
+ width: parent ? parent.width : implicitWidth
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: mod.active ? Theme.text : Theme.subtext
+ text: {
+ if (!mod.doc) return "checking…";
+ const parts = [];
+ if (mod.toInstall) parts.push(mod.toInstall + " to install");
+ if (mod.toBuild) parts.push(mod.toBuild + " to build");
+ return parts.join(" · ") || "up to date";
+ }
+ }
+ }
+
+ page: Component {
+ Page {
+ title: "Updates"
+ UpdatesPage {
+ width: parent.width
+ up: mod
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Register it.** In `desktop/shell.qml`, add `import "modules/updates"` after `import "modules/status"`, and `UpdatesModule {},` after `ClipboardModule {},` in the `modules:` list. Editing `shell.qml` forces the qmldir rescan for the new directory (AGENTS.md, Reloading).
+
+- [ ] **Step 3: Create a minimal `UpdatesPage.qml`** so the module resolves. Task 7 replaces it:
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+Column {
+ required property var up
+}
+```
+
+- [ ] **Step 4: Ask the user** to open the drawer and confirm the Slackware-logo tile showing "checking…" and then a count. If the tile is missing, the module failed to load: ask the user for the drawer's terminal or log output rather than starting a `qs` from a tool call.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/updates/UpdatesModule.qml desktop/modules/updates/UpdatesPage.qml desktop/shell.qml
+git commit -S -F - <<'EOF'
+feat(updates): the drawer tile
+
+The tile counts what can be installed now (Slackware and the binary
+repo) and what only needs a build, and goes accent when there is
+something to install. It runs updatesctl while the drawer is open and
+not otherwise; a result over 30 minutes old is followed by a refresh
+that runs nvchecker, never after a refresh, so a failing nvchecker
+cannot loop.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 7: The page
+
+**Files:**
+- Modify: `desktop/modules/updates/UpdatesPage.qml`
+
+- [ ] **Step 1: Write the page**
+
+```qml
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// One section per action, in the order they are done, each hidden when it
+// has nothing and no error. Read-only: installing and building stay in the
+// terminal.
+Column {
+ id: page
+
+ required property var up
+ readonly property var d: up.doc
+
+ spacing: 16
+
+ function tracked(list) {
+ return list.map(r => ({ name: r.name, repo: r.repo, ver: r.from + " → " + r.to }));
+ }
+
+ readonly property var sections: !d ? [] : [
+ { title: "Slackware upgrades", err: d.errors.mirror || "",
+ rows: d.slackware.upgrade.map(r => ({ name: r.name, repo: "", ver: r.from + " → " + r.to })) },
+ { title: "New in Slackware", err: "",
+ rows: d.slackware.new.map(r => ({ name: r.name, repo: "", ver: r.version })) },
+ { title: "Removed from Slackware", err: "",
+ rows: d.slackware.removed.map(r => ({ name: r.name, repo: "", ver: "removed " + r.date })) },
+ { title: "Upgrade here", err: d.errors.repo || "", rows: tracked(d.upgrade) },
+ { title: "Awaiting build", err: "", rows: tracked(d.build) },
+ { title: "Upstream newer", err: d.errors.nvchecker || "", rows: tracked(d.upstream) },
+ { title: "App updates", err: "", rows: tracked(d.apps) },
+ ]
+
+ function ago(t) {
+ const m = Math.round((Date.now() / 1000 - t) / 60);
+ return m < 1 ? "just now" : m < 60 ? m + " min ago" : Math.round(m / 60) + " h ago";
+ }
+
+ Text {
+ visible: page.d !== null && page.up.toInstall === 0 && page.up.toBuild === 0
+ && page.d.upstream.length === 0 && page.d.apps.length === 0
+ text: "Up to date"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ }
+
+ Repeater {
+ model: page.sections
+
+ Column {
+ required property var modelData
+ width: page.width
+ spacing: 4
+ visible: modelData.rows.length > 0 || modelData.err !== ""
+
+ Text {
+ text: modelData.title + (modelData.rows.length ? " " + modelData.rows.length : "")
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ }
+
+ Text {
+ visible: modelData.err !== ""
+ text: modelData.err
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3; italic: true }
+ }
+
+ Repeater {
+ model: modelData.rows
+
+ Item {
+ required property var modelData
+ width: page.width
+ height: 22
+
+ Text {
+ anchors { left: parent.left; right: repo.left; rightMargin: 8; verticalCenter: parent.verticalCenter }
+ elide: Text.ElideRight
+ textFormat: Text.PlainText
+ text: modelData.name
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Text {
+ id: repo
+ anchors { right: ver.left; rightMargin: 12; verticalCenter: parent.verticalCenter }
+ text: modelData.repo
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Text {
+ id: ver
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ textFormat: Text.PlainText
+ text: modelData.ver
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: 10
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: page.up.checking ? "checking…"
+ : page.d && page.d.checked ? "checked " + page.ago(page.d.checked) : ""
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+
+ Button {
+ text: "Refresh"
+ enabled: !page.up.checking
+ onClicked: page.up.run(true)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Ask the user** to open the page and check: section order, versions readable, the repo tag column, the footer age, and that Refresh shows "checking…" and then updates (nvchecker takes a while). Fix anything they report.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/modules/updates/UpdatesPage.qml
+git commit -S -F - <<'EOF'
+feat(updates): the page, one section per next action
+
+Slackware upgrades, new and removed first, then upgrade here, awaiting
+build, upstream newer, app updates: the order the work is done in. Each
+row names the package, the repo it comes from and the one version jump
+its action covers. A source that could not be read says so under the
+section it empties instead of reading as nothing pending.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+### Task 8: Docs
+
+**Files:**
+- Create: `desktop/modules/updates/README.md`
+- Modify: `AGENTS.md` (the `desktop/` line of the layout block)
+
+- [ ] **Step 1: Write the README**
+
+```markdown
+# updates
+
+What to do next to bring this machine up to date, as a drawer page. Each
+pending update appears once, under the action it needs:
+
+- **Slackware upgrades / new / removed**: the slackpkg mirror's
+ `PACKAGES.TXT` and `ChangeLog.txt` against `/var/log/packages`, the
+ blacklist honoured. `upgrade-all`, `install-new` and `clean-system`.
+- **Upgrade here**: the binary repo (the `danix` slackpkg+ mirror) holds a
+ newer build than installed.
+- **Awaiting build**: the recipe (the user's `.info` or SBo's) has caught up
+ with upstream, the build has not.
+- **Upstream newer**: the recipe lags upstream; bump the `.info` or the
+ slackrepo hint.
+- **App updates**: AppImages and flatpaks behind upstream.
+
+`updatesctl` does all of it and prints JSON; the QML only draws. Upstream
+versions are nvchecker's `new_ver.json`; a result over 30 minutes old is
+followed by `updatesctl --refresh`, which runs nvchecker first. Nothing runs
+while the drawer is closed.
+
+**The mirror and the repo are on a hard NFS mount.** A read during a NAS
+outage blocks, so every NFS read is a `cat` child with a 5s timeout, and an
+unreachable source shows as a line under the section it empties.
+
+**SBo bumps through slackrepo hints are not visible here:** the hint files
+live on the build VM. Such a package stays under *Upstream newer* until its
+build lands in the repo.
+
+Tested with:
+
+ /usr/bin/python3 desktop/modules/updates/test_updatesctl.py
+```
+
+- [ ] **Step 2: Update AGENTS.md.** In the layout block, change
+
+```
+ desktop/ the drawer (SUPER alone): sound, mail, VMs, status, performance,
+ clipboard, appearance
+```
+
+to
+
+```
+ desktop/ the drawer (SUPER alone): sound, mail, VMs, status, performance,
+ clipboard, updates, appearance
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add desktop/modules/updates/README.md AGENTS.md
+git commit -S -F - <<'EOF'
+docs(updates): the module's README
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
+EOF
+```
+
+---
+
+## After the plan
+
+Retiring conky's updates card (`bin/updates-sample.sh`, `widgets/updates.lua` in `conky-theme-udt`) waits until the user says the tile is trusted. It is a separate commit in that repo.