aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-15 12:11:58 +0200
committerDanilo M. <danix@danix.xyz>2026-10-15 12:11:58 +0200
commit8e951468ab0a26844d02f179a15d778591a68a21 (patch)
treecc79acce306cc66b6015b711ef2d06c267cc0e9f
parent0062ef6b5ffc3d57f3559118929ed9aed58b21ee (diff)
downloadquickshell-8e951468ab0a26844d02f179a15d778591a68a21.tar.gz
quickshell-8e951468ab0a26844d02f179a15d778591a68a21.zip
docs(plan): the updates module, and two spec corrections
Drafting the code found two holes in the spec. Awaiting build was limited to the user's own recipes, so an SBo package whose recipe had caught up but was not yet built (nessus) showed in no section; it now covers any recipe. And "the newest ChangeLog line decides" would never call a package new once it had been upgraded after being added; only Added and Removed lines count now. The prefix rule for kernel suffixes is dropped as redundant: version order already ranks a suffixed version newer, which is never behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-10-15-updates.md1196
-rw-r--r--docs/superpowers/specs/2026-10-15-updates-design.md29
2 files changed, 1213 insertions, 12 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.
diff --git a/docs/superpowers/specs/2026-10-15-updates-design.md b/docs/superpowers/specs/2026-10-15-updates-design.md
index e6a3458..54ebfbf 100644
--- a/docs/superpowers/specs/2026-10-15-updates-design.md
+++ b/docs/superpowers/specs/2026-10-15-updates-design.md
@@ -42,10 +42,12 @@ matches, so a row shows the next step and nothing after it.
1. **Slackware**, three groups, all from the mirror:
- *Upgrade*: an installed package whose name is on the mirror with a
different version or build.
- - *New*: the ChangeLog's newest line for the package says `Added.`, it is
- on the mirror, and it is not installed. These are slackpkg's
- `install-new` semantics.
- - *Removed*: the ChangeLog's newest line for the package says `Removed.`,
+ - *New*: of the ChangeLog's `Added.` and `Removed.` lines for the package,
+ the newest says `Added.`; it is on the mirror and not installed. These
+ are slackpkg's `install-new` semantics. Other lines (`Upgraded.`,
+ `Rebuilt.`) are ignored, or a package added and later upgraded would
+ never count as new.
+ - *Removed*: of the same lines, the newest says `Removed.`,
it is installed, and it is not on the mirror. The row carries the date of
that ChangeLog entry.
@@ -54,11 +56,13 @@ matches, so a row shows the next step and nothing after it.
skipped in all three, as slackpkg skips them; so are blacklisted names.
2. **Upgrade here**: B newer than I. The package is built and sitting in the
repo.
-3. **Awaiting build**: R is the user's (not `SBo`), R equals U, and B is older
- than U. The SlackBuild is bumped and only needs building.
+3. **Awaiting build**: R is at or past U, B is older than U, and the package
+ is installed. The SlackBuild, the user's or SBo's, is current and only
+ needs building.
4. **Upstream newer**: U newer than both R and B. The SlackBuild or its hint
- needs bumping, then a build. `SBo` rows land here until the build appears
- in B.
+ needs bumping, then a build. An `SBo` recipe that lags upstream lands
+ here: the bump is a hint file on the build VM. Shown when the package is
+ installed, or when the recipe is the user's (repo work either way).
5. **App updates**: I comes from AppImage or flatpak and is older than U.
"Newer" and "older" are version order, never equality: an installed 2.1.278
@@ -83,10 +87,11 @@ Rows are `{name, repo, from, to}`; Slackware rows are `{name, from, to}`,
**Version order** is a small `vercmp`: split on non-alphanumerics, compare
numeric chunks as numbers and alphabetic chunks as strings, numbers before
-letters. It replaces nvtable's normalised equality, including its rule that
-an installed version extending upstream at a component boundary (a kernel
-suffix, a git hash) counts as equal; that rule is kept as a prefix check
-before ordering.
+letters; when one runs out, the longer is newer unless its next chunk is
+alphabetic (a pre-release). It replaces nvtable's normalised equality. Its
+rule that an installed version extending upstream at a component boundary (a
+kernel suffix, a git hash) counts as equal needs no special case: the longer
+version orders as newer, which is never behind.
**NFS reads** (`PACKAGES.TXT` of the repo and of the mirror, the mirror's
`ChangeLog.txt`) go through `subprocess.run(["cat", path], timeout=5)`. The