aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-10-20-sb-builder-phase4.md1980
1 files changed, 1980 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md b/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md
new file mode 100644
index 0000000..2608459
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md
@@ -0,0 +1,1980 @@
+# sb-builder phase 4: hints, 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:** Manage slackrepo hints from sb-builder: upstream check on the desktop, hint update/new/delete/fix-current as helper jobs, diff and revert after each.
+
+**Architecture:** mkhint's hint logic is ported into `remote/sbb-remote` as text edits (stdlib: `re`, `urllib`, `hashlib`), run as job kinds whose argv is the helper itself (`_hint`). The GUI runs `nvchecker` locally over a repo-tracked `nvchecker.toml` (`sb_builder/upstream.py`), shows a Hints screen (`sb_builder/hints_tab.py`) and a diff/revert dialog when a hint job ends.
+
+**Tech Stack:** Python 3.11+ stdlib, PyQt6 (GUI only), nvchecker CLI on the desktop.
+
+Spec: `docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md`.
+
+Every code step below was run before this plan was written: the diffs apply
+to `master` at `8ad8b0b` with `git apply` and the suite ends at 113 tests,
+OK. Two `ResourceWarning: subprocess ... is still running` lines are
+expected (detached `_run`).
+
+## Files
+
+| File | Change |
+|---|---|
+| `remote/sbb-remote` | hint text functions, hint job kinds, `_hint`, `hint-diff`, `hint-revert`, `hint-delete`, `job-result`, config keys `phantom_deps`, `manifest_packages`, hyphenated job ids, VERSION 0.5.0 |
+| `remote/config.example.toml` | the two new keys |
+| `sb_builder/__init__.py` | VERSION 0.5.0 |
+| `nvchecker.toml` | new: the VM's 64 stanzas |
+| `sb_builder/upstream.py` | new: nvchecker config, JSON log parsing, states, `Checker` |
+| `sb_builder/config.py`, `config.example.toml` | `[hints] nvchecker_keyfile` |
+| `sb_builder/hints_tab.py` | new: Hints screen, `HintResultDialog` |
+| `sb_builder/mainwindow.py` | Hints ribbon entry and tab, job end -> `job-result` -> dialog |
+| `sb_builder/packages.py` | detail pane: New hint, Open in Hints |
+| `tests/test_remote.py`, `tests/test_upstream.py` (new), `tests/test_gui.py` | tests |
+| `README.md`, `AGENTS.md`, phase 4 spec | docs |
+
+---
+
+### Task 1: helper, hint editing and hint jobs
+
+**Files:**
+- Modify: `remote/sbb-remote`, `remote/config.example.toml`, `sb_builder/__init__.py`
+- Test: `tests/test_remote.py`
+
+Key behaviors (see spec Part 2): `swap_version` never touches `MD5SUM*`
+values; `apply_version` pins an unpinned (DELREQUIRES-only) hint from the
+`.info`, downloads only the first URL of each DOWNLOAD pair, and with
+`--no-dl` replaces every `DOWNLOAD*`/`MD5SUM*` line by `DOWNLOAD="no"`;
+`cmd_hint` computes before it backs up, so a failed package keeps its hint
+and old `.bak`; `hint-revert` without `.bak` deletes (reverting a new hint).
+
+- [ ] **Step 1: Write the failing tests.** Save the diff below as `/tmp/t1_test.diff`, then `git apply /tmp/t1_test.diff`.
+
+```diff
+diff --git c/tests/test_remote.py i/tests/test_remote.py
+index db3d0a1..3e546ef 100644
+--- c/tests/test_remote.py
++++ i/tests/test_remote.py
+@@ -18,6 +18,9 @@
+
+ import contextlib
+ import fcntl
++import functools
++import hashlib
++import http.server
+ import importlib.machinery
+ import importlib.util
+ import io
+@@ -25,6 +28,7 @@ import json
+ import os
+ import shutil
+ import sqlite3
++import sys
+ import tempfile
+ import threading
+ import time
+@@ -179,6 +183,275 @@ class KindsTest(Base):
+ sbb.cmd_job_start(self.cfg, "rm", "-rf", "/")
+
+
++FLATPAK_INFO = ('PRGNAM="flatpak"\nVERSION="1.16.0"\nHOMEPAGE="https://flatpak.org/"\n'
++ 'DOWNLOAD="https://example.org/flatpak-1.16.0.tar.xz"\n'
++ 'MD5SUM="bdf866646fb312a341f84669f625488b"\n'
++ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n'
++ 'REQUIRES="bubblewrap ostree"\nMAINTAINER="Some One"\nEMAIL="one@example.org"\n')
++
++
++class HintTextTest(unittest.TestCase):
++ def test_swap_forms(self):
++ text = ('VERSION="1.2.00"\nDOWNLOAD="https://example.org/build_1200/x-1.2.00.tar.gz"\n'
++ 'MD5SUM="00001200aa"\nDELREQUIRES="lib1200"\n')
++ self.assertEqual(sbb.swap_version(text, "1.2.00", "1.2.15"),
++ 'VERSION="1.2.15"\nDOWNLOAD="https://example.org/build_1215/x-1.2.15.tar.gz"\n'
++ 'MD5SUM="00001200aa"\nDELREQUIRES="lib1200"\n')
++
++ def test_swap_underscore_as_dash(self):
++ text = 'VERSION="2026_07_07"\nDOWNLOAD="https://example.org/2026-07-07.tgz"\n'
++ self.assertEqual(sbb.swap_version(text, "2026_07_07", "2026_08_01"),
++ 'VERSION="2026_08_01"\nDOWNLOAD="https://example.org/2026-08-01.tgz"\n')
++
++ def test_md5_values_untouched(self):
++ text = 'VERSION="4215"\nMD5SUM="aa4215bb \\\n cc4215dd"\nMD5SUM_x86_64="4215"\n'
++ self.assertEqual(sbb.swap_version(text, "4215", "4216"),
++ 'VERSION="4216"\nMD5SUM="aa4215bb \\\n cc4215dd"\nMD5SUM_x86_64="4215"\n')
++
++ def test_tokens_set_drop(self):
++ text = 'A="x \\\n y"\nB=plain\nDOWNLOAD_x86_64=""\n'
++ self.assertEqual(sbb.hint_tokens(text, "A"), ["x", "y"])
++ self.assertEqual(sbb.hint_tokens(text, "B"), ["plain"])
++ self.assertEqual(sbb.hint_tokens(text, "C"), [])
++ self.assertEqual(sbb.hint_set(text, "B", "1 2"), 'A="x \\\n y"\nB="1 2"\nDOWNLOAD_x86_64=""\n')
++ self.assertEqual(sbb.hint_set("Z=1", "B", "2"), 'Z=1\nB="2"\n')
++ self.assertEqual(sbb.hint_drop(text, r"(?:A|DOWNLOAD\w*)"), "B=plain\n")
++
++ def test_new_hint(self):
++ self.assertEqual(sbb.new_hint(FLATPAK_INFO, ["ostree", "rust-opt"]),
++ 'VERSION="1.16.0"\nDOWNLOAD="https://example.org/flatpak-1.16.0.tar.xz"\n'
++ 'MD5SUM="bdf866646fb312a341f84669f625488b"\n'
++ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n'
++ '#REQUIRES="bubblewrap ostree"\nARCH="x86_64"\nDELREQUIRES="ostree"\n')
++
++ def test_merge_delrequires(self):
++ self.assertEqual(sbb.merge_delrequires('DELREQUIRES="a"\n', ["b", "a"]), 'DELREQUIRES="a b"\n')
++ self.assertIsNone(sbb.merge_delrequires('DELREQUIRES="a b"\n', ["b"]))
++ self.assertEqual(sbb.merge_delrequires("", ["b"]), 'DELREQUIRES="b"\n')
++
++
++class Served:
++ """Files of a temp dir over http on localhost, for md5 downloads."""
++
++ def __init__(self, test, files):
++ root = os.path.join(test.tmp, "www")
++ os.makedirs(root)
++ for name, data in files.items():
++ with open(os.path.join(root, name), "wb") as f:
++ f.write(data)
++ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=root)
++ handler.log_message = lambda *a: None
++ self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
++ threading.Thread(target=self.server.serve_forever, daemon=True).start()
++ test.addCleanup(self.server.server_close)
++ test.addCleanup(self.server.shutdown)
++ self.url = f"http://127.0.0.1:{self.server.server_port}"
++
++
++def md5(data):
++ return hashlib.md5(data).hexdigest()
++
++
++class ApplyVersionTest(Base):
++ def setUp(self):
++ super().setUp()
++ self.www = Served(self, {"x-2.0.tgz": b"two", "dep.tgz": b"dep"})
++ self.log = []
++
++ def apply(self, text, new, no_dl=False, info=""):
++ return sbb.apply_version(text, info, new, no_dl, self.log.append)
++
++ def test_download_refreshes_first_md5_only(self):
++ u = self.www.url
++ text = (f'VERSION="1.0"\nDOWNLOAD="{u}/x-1.0.tgz \\\n {u}/dep.tgz"\n'
++ 'MD5SUM="old1 \\\n keep2"\nDOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nARCH="x86_64"\n')
++ self.assertEqual(self.apply(text, "2.0"),
++ f'VERSION="2.0"\nDOWNLOAD="{u}/x-2.0.tgz \\\n {u}/dep.tgz"\n'
++ f'MD5SUM="{md5(b"two")} \\\n keep2"\nDOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\n'
++ 'ARCH="x86_64"\n')
++ self.assertEqual(self.log, [f"downloading {u}/x-2.0.tgz"])
++
++ def test_arch_pair_and_unsupported(self):
++ u = self.www.url
++ text = (f'VERSION="1.0"\nDOWNLOAD="UNSUPPORTED"\nMD5SUM=""\n'
++ f'DOWNLOAD_x86_64="{u}/x-1.0.tgz"\nMD5SUM_x86_64="old"\n')
++ self.assertIn(f'MD5SUM_x86_64="{md5(b"two")}"', self.apply(text, "2.0"))
++ self.assertIn('DOWNLOAD="UNSUPPORTED"\nMD5SUM=""\n', self.apply(text, "2.0"))
++
++ def test_unpinned_hint_pins_from_info(self):
++ u = self.www.url
++ info = (f'PRGNAM="x"\nVERSION="1.0"\nDOWNLOAD="{u}/x-1.0.tgz"\nMD5SUM="old"\n'
++ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES="a"\n')
++ self.assertEqual(self.apply('DELREQUIRES="rust-opt"\n', "2.0", info=info),
++ f'VERSION="2.0"\nDOWNLOAD="{u}/x-2.0.tgz"\nMD5SUM="{md5(b"two")}"\n'
++ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nARCH="x86_64"\nDELREQUIRES="rust-opt"\n')
++ self.assertEqual(self.apply('DELREQUIRES="rust-opt"\n', "2.0", no_dl=True, info=info),
++ 'VERSION="2.0"\nARCH="x86_64"\nDELREQUIRES="rust-opt"\nDOWNLOAD="no"\n')
++
++ def test_no_dl(self):
++ text = ('VERSION="1.0"\nDOWNLOAD="https://example.org/x-1.0.tgz \\\n https://example.org/d"\n'
++ 'MD5SUM="a \\\n b"\nDOWNLOAD_x86_64="https://example.org/y"\nMD5SUM_x86_64="c"\n'
++ 'ARCH="x86_64"\n')
++ self.assertEqual(self.apply(text, "2.0", no_dl=True),
++ 'VERSION="2.0"\nARCH="x86_64"\nDOWNLOAD="no"\n')
++ self.assertEqual(self.log, [])
++
++ def test_no_version_no_download(self):
++ self.assertEqual(self.apply('VERSION="1.0"\nDOWNLOAD="x"\nMD5SUM="m"\n', None),
++ 'VERSION="1.0"\nDOWNLOAD="x"\nMD5SUM="m"\n')
++
++ def test_download_failure_raises(self):
++ with self.assertRaises(OSError):
++ self.apply(f'VERSION="1.0"\nDOWNLOAD="{self.www.url}/x-1.0.tgz"\nMD5SUM="m"\n', "3.0")
++
++
++class HintJobsTest(Base):
++ def setUp(self):
++ super().setUp()
++ self.www = Served(self, {"x-2.0.tgz": b"two"})
++ self.cfg["phantom_deps"] = ["rust-opt", "ostree"]
++ self.info = (f'PRGNAM="x"\nVERSION="1.0"\nDOWNLOAD="{self.www.url}/x-1.0.tgz"\nMD5SUM="old"\n'
++ 'DOWNLOAD_x86_64=""\nMD5SUM_x86_64=""\nREQUIRES="rust-opt a"\n')
++ self.hint = os.path.join(self.cfg["hintdir"], "x.hint")
++
++ def read(self, path):
++ with open(path) as f:
++ return f.read()
++
++ def run_hint(self, *args):
++ out = io.StringIO()
++ with contextlib.redirect_stdout(out):
++ try:
++ sbb.cmd_hint(self.cfg, *args)
++ rc = 0
++ except sbb.Error:
++ rc = 1
++ lines = out.getvalue().splitlines()
++ self.assertTrue(lines[-1].startswith("SBB-RESULT "))
++ return rc, json.loads(lines[-1].split(" ", 1)[1]), lines
++
++ def test_kinds_argv(self):
++ self.make_pkg("misc/x", self.info)
++ me = [sys.executable, os.path.abspath(sbb.__file__), "_hint"]
++ self.assertEqual(sbb.KINDS["hint-update"](self.cfg, ["x=2.0", "--no-dl"]),
++ (me + ["hint-update", "x=2.0", "--no-dl"], {}))
++ self.assertEqual(sbb.KINDS["hint-new"](self.cfg, ["x", "2.0"]), (me + ["hint-new", "x", "2.0"], {}))
++ self.assertEqual(sbb.KINDS["fix-current"](self.cfg, []), (me + ["fix-current"], {}))
++ self.assertFalse(sbb.signs("hint-update", ["x=2.0"]))
++
++ def test_kinds_reject(self):
++ self.make_pkg("misc/x", self.info)
++ self.make_pkg("misc/neovim", self.info)
++ self.make_pkg("misc/shadowed", self.info)
++ os.makedirs(os.path.join(self.cfg["repo_dir"], "misc/shadowed"), exist_ok=True)
++ with open(os.path.join(self.cfg["repo_dir"], "misc/shadowed/shadowed.hint"), "w") as f:
++ f.write("")
++ for kind, args, why in (("hint-update", [], "at least one"),
++ ("hint-update", ["x"], "pkg=version"),
++ ("hint-update", ["x=1;rm"], "bad version"),
++ ("hint-update", ["x=" + "1" * 65], "bad version"),
++ ("hint-update", ["nope=1"], "no such package"),
++ ("hint-update", ["x=1", "x=2"], "twice"),
++ ("hint-update", ["x=1", "--force"], "unknown option"),
++ ("hint-update", ["neovim=1"], "bundled deps"),
++ ("hint-update", ["shadowed=1"], "not managed"),
++ ("hint-new", [], "hint-new <pkg>"),
++ ("hint-new", ["x", "1", "2"], "hint-new <pkg>"),
++ ("hint-new", ["neovim", "1"], "bundled deps"),
++ ("fix-current", ["x"], "no arguments")):
++ with self.subTest(kind=kind, args=args), self.assertRaisesRegex(sbb.Error, why):
++ sbb.KINDS[kind](self.cfg, args)
++ sbb.KINDS["hint-new"](self.cfg, ["neovim"]) # no version: no bundled deps to touch
++ self.make_pkg("misc/y", self.info, hint="")
++ with self.assertRaisesRegex(sbb.Error, "exists"):
++ sbb.KINDS["hint-new"](self.cfg, ["y"])
++
++ def test_update_backs_up_and_reports(self):
++ self.make_pkg("misc/x", self.info, hint='DELREQUIRES="rust-opt"\n')
++ self.make_pkg("misc/y", self.info.replace("x-1.0", "y-1.0"), hint='VERSION="1.0"\n'
++ f'DOWNLOAD="{self.www.url}/y-1.0.tgz"\nMD5SUM="old"\n')
++ rc, result, lines = self.run_hint("hint-update", "y=2.0", "x=2.0")
++ self.assertEqual(rc, 1)
++ self.assertEqual(result["ok"], ["x"])
++ self.assertEqual([p for p, _why in result["failed"]], ["y"])
++ self.assertIn("x: downloading", "\n".join(lines))
++ self.assertIn(md5(b"two"), self.read(self.hint))
++ self.assertEqual(self.read(self.hint + ".bak"), 'DELREQUIRES="rust-opt"\n')
++ y = os.path.join(self.cfg["hintdir"], "y.hint")
++ self.assertIn('MD5SUM="old"', self.read(y)) # failed: left as it was
++ self.assertFalse(os.path.exists(y + ".bak"))
++
++ def test_update_without_hint_creates_one(self):
++ self.make_pkg("misc/x", self.info)
++ os.makedirs(self.cfg["hintdir"])
++ with open(self.hint + ".bak", "w") as f:
++ f.write("stale")
++ rc, result, _ = self.run_hint("hint-update", "x=2.0", "--no-dl")
++ self.assertEqual((rc, result), (0, {"ok": ["x"], "failed": []}))
++ self.assertEqual(self.read(self.hint), 'VERSION="2.0"\n#REQUIRES="rust-opt a"\nARCH="x86_64"\n'
++ 'DELREQUIRES="rust-opt"\nDOWNLOAD="no"\n')
++ self.assertFalse(os.path.exists(self.hint + ".bak")) # revert = delete
++
++ def test_new(self):
++ self.make_pkg("misc/x", self.info)
++ rc, result, _ = self.run_hint("hint-new", "x")
++ self.assertEqual((rc, result), (0, {"ok": ["x"], "failed": []}))
++ self.assertIn('DELREQUIRES="rust-opt"', self.read(self.hint))
++ self.assertIn('MD5SUM="old"', self.read(self.hint))
++
++ def test_fix_current(self):
++ self.make_pkg("misc/x", self.info, hint='DELREQUIRES="b"\n')
++ self.make_pkg("misc/z", 'VERSION="1"\nREQUIRES="ostree"\n')
++ self.make_pkg("misc/w", 'VERSION="1"\nREQUIRES="a"\n')
++ rc, result, _ = self.run_hint("fix-current")
++ self.assertEqual((rc, result), (0, {"ok": ["x", "z"], "failed": []}))
++ self.assertEqual(self.read(self.hint), 'DELREQUIRES="b rust-opt"\n')
++ z = os.path.join(self.cfg["hintdir"], "z.hint")
++ self.assertEqual(self.read(z), 'DELREQUIRES="ostree"\n')
++ self.assertFalse(os.path.exists(z + ".bak"))
++ rc, result, _ = self.run_hint("fix-current")
++ self.assertEqual(result, {"ok": [], "failed": []})
++ self.assertEqual(self.read(self.hint + ".bak"), 'DELREQUIRES="b"\n')
++
++ def test_diff_revert_delete(self):
++ self.make_pkg("misc/x", self.info, hint="A=1\n")
++ self.assertEqual(sbb.cmd_hint_diff(self.cfg, "x"), {"bak": None, "current": "A=1\n"})
++ self.assertEqual(sbb.cmd_hint_delete(self.cfg, "x"), {"ok": True})
++ self.assertEqual(sbb.cmd_hint_diff(self.cfg, "x"), {"bak": "A=1\n", "current": None})
++ with self.assertRaisesRegex(sbb.Error, "no hint"):
++ sbb.cmd_hint_delete(self.cfg, "x")
++ self.assertEqual(sbb.cmd_hint_revert(self.cfg, "x"), {"ok": True})
++ self.assertEqual(self.read(self.hint), "A=1\n")
++ self.assertFalse(os.path.exists(self.hint + ".bak"))
++ self.assertEqual(sbb.cmd_hint_revert(self.cfg, "x"), {"ok": True}) # no .bak: revert a new hint
++ self.assertFalse(os.path.exists(self.hint))
++ with self.assertRaisesRegex(sbb.Error, "nothing to revert"):
++ sbb.cmd_hint_revert(self.cfg, "x")
++ with self.assertRaisesRegex(sbb.Error, "no backup"):
++ sbb.cmd_hint_diff(self.cfg, "x")
++ with self.assertRaisesRegex(sbb.Error, "no such package"):
++ sbb.cmd_hint_diff(self.cfg, "../x")
++
++ def test_job_result(self):
++ jid, d = self.make_job(["true"])
++ with open(os.path.join(d, "log"), "w") as f:
++ f.write('x: downloading u\nSBB-RESULT {"ok": ["x"], "failed": []}\n{"error": "1 failed"}\n')
++ self.assertEqual(sbb.cmd_job_result(self.cfg, jid), {"ok": ["x"], "failed": []})
++ jid2 = "20260101-000001-test"
++ os.mkdir(os.path.join(self.tmp, "jobs", jid2))
++ open(os.path.join(self.tmp, "jobs", jid2, "log"), "w").close()
++ with self.assertRaisesRegex(sbb.Error, "no result"):
++ sbb.cmd_job_result(self.cfg, jid2)
++
++ def test_end_to_end(self):
++ self.make_pkg("misc/x", self.info, hint='VERSION="1.0"\nDOWNLOAD="u"\nMD5SUM="m"\n')
++ rc, data = self.run_main("job-start", "hint-update", "x=2.0", "--no-dl")
++ d = os.path.join(self.tmp, "jobs", data["id"])
++ self.assertEqual(self.wait_status(d, ("ok", "failed", "lost"))["status"], "ok")
++ self.assertEqual(self.run_main("job-result", data["id"]), (0, {"ok": ["x"], "failed": []}))
++ self.assertEqual(self.read(self.hint), 'VERSION="2.0"\nDOWNLOAD="no"\n')
++
++
+ class MainTest(Base):
+ def test_unknown_command(self):
+ rc, data = self.run_main("nope")
+```
+
+- [ ] **Step 2: Run them, expect failures.** Run `python3 -m unittest discover -s tests`. Expected: `FAILED (errors=...)`, with `AttributeError: module 'sbb_remote' has no attribute 'swap_version'` (and the other new names) plus `VersionTest` still passing.
+
+- [ ] **Step 3: Implement.** Save the diff below as `/tmp/t1_code.diff`, then `git apply /tmp/t1_code.diff`.
+
+```diff
+diff --git c/remote/config.example.toml i/remote/config.example.toml
+index d275401..a489bff 100644
+--- c/remote/config.example.toml
++++ i/remote/config.example.toml
+@@ -12,3 +12,5 @@ hintdir = "/etc/slackrepo/SBo-danix/hintfiles"
+ default_hintdir = "" # searched after hintdir when set
+ genreprc = "/root/.genreprc" # REPOSOWNERGPG names the signing key
+ slackrepo_logdir = "/var/log/slackrepo/SBo-danix"
++phantom_deps = [] # deps unneeded on -current, DELREQUIREd by hint-new, fix-current
++manifest_packages = ["neovim", "openvino"] # bundled deps: hint-update refuses them (phase 4b)
+diff --git c/remote/sbb-remote i/remote/sbb-remote
+index 25427c0..230dde6 100755
+--- c/remote/sbb-remote
++++ i/remote/sbb-remote
+@@ -25,6 +25,7 @@ its arguments, this file validates them and builds the argv itself.
+
+ import fcntl
+ import glob
++import hashlib
+ import json
+ import os
+ import re
+@@ -37,8 +38,9 @@ import sys
+ import tempfile
+ import time
+ import tomllib
++import urllib.request
+
+-VERSION = "0.4.1"
++VERSION = "0.5.0"
+ DEFAULTS = {
+ "state_dir": "/var/lib/sb-builder",
+ "repo_dir": "/var/lib/sbopkg/SBo-danix",
+@@ -51,11 +53,14 @@ DEFAULTS = {
+ "default_hintdir": "",
+ "genreprc": "/root/.genreprc",
+ "slackrepo_logdir": "/var/log/slackrepo/SBo-danix",
++ "phantom_deps": [], # deps unneeded on -current: hint-new and fix-current DELREQUIRES them
++ "manifest_packages": ["neovim", "openvino"], # bundled deps: left to mkhint until phase 4b
+ }
+ FLAGS = ("--yes", "--no")
+-JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-\d+)?$")
++JOB_ID = re.compile(r"^\d{8}-\d{6}-[a-z]+(-[a-z]+)*(-\d+)?$")
+ PKG = re.compile(r"^[^/]+/([^/]+)/\1\.SlackBuild$")
+ PKGNAME = re.compile(r"^[A-Za-z0-9._+-]+$")
++VERSION_ARG = re.compile(r"^[A-Za-z0-9._+-]{1,64}$")
+ ACTIONS = ("build", "rebuild", "update", "remove")
+ PRESET = "/usr/libexec/gpg-preset-passphrase"
+ ITEM = re.compile(r"^[A-Za-z0-9._+-]+/[A-Za-z0-9._+-]+$") # category/pkg
+@@ -85,8 +90,9 @@ def load_config(path=None):
+ if type(v) is not type(DEFAULTS[k]):
+ raise Error(f"config: {k}: expected {type(DEFAULTS[k]).__name__}")
+ cfg[k] = v
+- if not all(isinstance(o, str) for o in cfg["overlays"]):
+- raise Error("config: overlays: expected list of strings")
++ for k in ("overlays", "phantom_deps", "manifest_packages"):
++ if not all(isinstance(o, str) for o in cfg[k]):
++ raise Error(f"config: {k}: expected list of strings")
+ return cfg
+
+
+@@ -138,8 +144,71 @@ def kind_publish(cfg, args):
+ return ["sb-publish"], {}
+
+
++def _pkgmap(cfg):
++ """{package: category/package} of the repo."""
++ return {os.path.basename(d): os.path.relpath(d, cfg["repo_dir"])
++ for d in _pkgdirs(cfg["repo_dir"], "*/*")}
++
++
++def _hint_target(cfg, pkg, pkgs=None):
++ """(category/pkg, <hintdir>/<pkg>.hint); refuses a hint slackrepo finds elsewhere first."""
++ pkgs = pkgs or _pkgmap(cfg)
++ if not PKGNAME.match(pkg or "") or pkg not in pkgs:
++ raise Error(f"no such package: {pkg}")
++ target = os.path.join(cfg["hintdir"], pkg + ".hint")
++ found = find_hint(cfg, pkgs[pkg])
++ if found and found != target:
++ raise Error(f"hint lives at {found}, not managed")
++ return pkgs[pkg], target
++
++
++def hint_args(cfg, kind, args):
++ """Validated ([(pkg, version or None)], no_dl) of a hint-update or hint-new job."""
++ no_dl = "--no-dl" in args
++ for o in (a for a in args if a.startswith("--") and a != "--no-dl"):
++ raise Error(f"{kind}: unknown option {o}")
++ rest = [a for a in args if a != "--no-dl"]
++ if kind == "hint-new":
++ if not 1 <= len(rest) <= 2:
++ raise Error("usage: hint-new <pkg> [<version>] [--no-dl]")
++ pairs = [(rest[0], rest[1] if len(rest) == 2 else None)]
++ else:
++ if not rest:
++ raise Error("hint-update needs at least one pkg=version")
++ if not all("=" in a for a in rest):
++ raise Error("hint-update: expected pkg=version")
++ pairs = [tuple(a.split("=", 1)) for a in rest]
++ pkgs = _pkgmap(cfg)
++ seen = set()
++ for pkg, ver in pairs:
++ if ver is not None and not VERSION_ARG.match(ver):
++ raise Error(f"bad version: {ver}")
++ _, target = _hint_target(cfg, pkg, pkgs)
++ if pkg in seen:
++ raise Error(f"{pkg} named twice")
++ seen.add(pkg)
++ if ver is not None and pkg in cfg["manifest_packages"]:
++ raise Error(f"{pkg}: bundled deps: use mkhint until phase 4b")
++ if kind == "hint-new" and os.path.exists(target):
++ raise Error(f"{pkg}: hint exists")
++ return pairs, no_dl
++
++
++def _self_job(kind):
++ """A job kind run by this file itself (_hint), argv-free like the others."""
++ def build(cfg, args):
++ if kind == "fix-current":
++ if args:
++ raise Error("fix-current takes no arguments")
++ else:
++ hint_args(cfg, kind, args)
++ return [sys.executable, os.path.abspath(__file__), "_hint", kind, *args], {}
++ return build
++
++
+ KINDS = {"sbpull": kind_sbpull, "sbshadow": kind_sbshadow,
+- "slackrepo": kind_slackrepo, "publish": kind_publish}
++ "slackrepo": kind_slackrepo, "publish": kind_publish,
++ **{k: _self_job(k) for k in ("hint-update", "hint-new", "fix-current")}}
+
+
+ def signs(kind, args):
+@@ -549,6 +618,234 @@ def cmd_inventory(cfg):
+ return {"columns": COLUMNS, "rows": rows}
+
+
++# ── hint text ───────────────────────────────────────────────────────────────
++# Hints are edited as text so comments, order and other keys stay as written.
++
++PIN_KEYS = ("VERSION", "DOWNLOAD", "MD5SUM", "DOWNLOAD_x86_64", "MD5SUM_x86_64")
++DL_PAIRS = (("DOWNLOAD", "MD5SUM"), ("DOWNLOAD_x86_64", "MD5SUM_x86_64"))
++SKIP_URLS = ("UNSUPPORTED", "UNTESTED")
++DL_TIMEOUT = 60
++
++
++def _assignment(key):
++ """KEY=value at line start; a quoted value may span lines. key is a regex."""
++ return re.compile(rf'^(?:{key})=(?:"([^"]*)"|(\S*))[^\n]*\n?', re.M)
++
++
++def hint_tokens(text, key):
++ """Whitespace-separated words of KEY's value, [] when unset or empty."""
++ m = _assignment(key).search(text)
++ if not m:
++ return []
++ value = m.group(1) if m.group(1) is not None else m.group(2)
++ return value.replace("\\\n", " ").split()
++
++
++def hint_set(text, key, value):
++ """KEY="value" in place of the first KEY line, else appended."""
++ line = f'{key}="{value}"\n'
++ m = _assignment(key).search(text)
++ if m:
++ return text[:m.start()] + line + text[m.end():]
++ return text + ("\n" if text and not text.endswith("\n") else "") + line
++
++
++def hint_drop(text, key):
++ return _assignment(key).sub("", text)
++
++
++def swap_version(text, old, new):
++ """mkhint's bump of old to new, everywhere but in MD5SUM* values.
++
++ Also the '-' form when either has '_' (dated URLs), and the dotless form of
++ a dotted old version inside URLs, as a whole number (build_4200 for 4.2.00).
++ """
++ pairs = [(old, new)]
++ if "_" in old or "_" in new:
++ pairs.append((old.replace("_", "-"), new.replace("_", "-")))
++ o, n = old.replace(".", ""), new.replace(".", "")
++ dotless = "." in old and o != n
++ out, pos = [], 0
++ for m in [*_assignment(r"MD5SUM\w*").finditer(text), None]:
++ part = text[pos:m.start() if m else len(text)]
++ for a, b in pairs:
++ part = part.replace(a, b)
++ if dotless:
++ part = re.sub(rf'(://[^\s"]*?)(?<!\d){re.escape(o)}(?!\d)',
++ lambda x: x.group(1) + n, part)
++ out.append(part)
++ if m:
++ out.append(m.group(0))
++ pos = m.end()
++ return "".join(out)
++
++
++def _pin(info):
++ """The version pin of an .info: its VERSION, DOWNLOAD* and MD5SUM* lines."""
++ lines = [m.group(0) for k in PIN_KEYS if (m := _assignment(k).search(info))]
++ if not lines or not lines[0].startswith("VERSION="):
++ raise Error("no VERSION in the .info")
++ return "".join(ln.rstrip("\n") + "\n" for ln in lines)
++
++
++def new_hint(info, phantom):
++ """mkhint's hint from an .info: the pin, REQUIRES commented, ARCH, phantom deps."""
++ text = _pin(info)
++ requires = hint_tokens(info, "REQUIRES")
++ m = _assignment("REQUIRES").search(info)
++ if m:
++ text += "#" + m.group(0).rstrip("\n") + "\n"
++ text += 'ARCH="x86_64"\n'
++ deps = [d for d in phantom if d in requires]
++ return text + f'DELREQUIRES="{" ".join(deps)}"\n' if deps else text
++
++
++def merge_delrequires(text, deps):
++ """text with deps added to DELREQUIRES, or None when all are there already."""
++ have = hint_tokens(text, "DELREQUIRES")
++ add = [d for d in dict.fromkeys(deps) if d not in have]
++ return hint_set(text, "DELREQUIRES", " ".join(have + add)) if add else None
++
++
++def _md5_url(url):
++ req = urllib.request.Request(url, headers={"User-Agent": "sbb-remote"})
++ h = hashlib.md5()
++ with urllib.request.urlopen(req, timeout=DL_TIMEOUT) as r:
++ for chunk in iter(lambda: r.read(1 << 16), b""):
++ h.update(chunk)
++ return h.hexdigest()
++
++
++def apply_version(text, info, new, no_dl, log):
++ """The hint text at version new (None: keep the version), log(str) per download.
++
++ An unpinned hint (DELREQUIRES only) gets the .info pin first. Downloads the
++ first URL of each DOWNLOAD pair for its md5; continuation URLs keep theirs,
++ as in mkhint. no_dl: no download, DOWNLOAD="no" in place of every
++ DOWNLOAD*/MD5SUM* line, since DOWNLOAD_$ARCH would override it.
++ """
++ if not hint_tokens(text, "VERSION"):
++ text = _pin(info) + ("" if hint_tokens(text, "ARCH") else 'ARCH="x86_64"\n') + text
++ if new is not None:
++ text = swap_version(text, hint_tokens(text, "VERSION")[0], new)
++ if no_dl:
++ return hint_drop(text, r"(?:DOWNLOAD|MD5SUM)\w*") + 'DOWNLOAD="no"\n'
++ if new is None:
++ return text
++ for dl, sums in DL_PAIRS:
++ urls = hint_tokens(text, dl)
++ if not urls or urls[0] in SKIP_URLS:
++ continue
++ log(f"downloading {urls[0]}")
++ text = hint_set(text, sums, " \\\n ".join([_md5_url(urls[0]), *hint_tokens(text, sums)[1:]]))
++ return text
++
++
++# ── hint jobs and commands ──────────────────────────────────────────────────
++
++def _read(path):
++ try:
++ with open(path, errors="replace") as f:
++ return f.read()
++ except FileNotFoundError:
++ return None
++
++
++def _write(path, text):
++ os.makedirs(os.path.dirname(path), exist_ok=True)
++ tmp = path + ".tmp"
++ with open(tmp, "w") as f:
++ f.write(text)
++ os.replace(tmp, path)
++
++
++def _save(target, text):
++ """Write a hint, its previous text to .bak; a new hint drops a stale .bak (revert = delete)."""
++ if os.path.exists(target):
++ shutil.copy2(target, target + ".bak")
++ elif os.path.exists(target + ".bak"):
++ os.remove(target + ".bak")
++ _write(target, text)
++
++
++def cmd_hint(cfg, kind=None, *args):
++ """Body of the hint jobs: log lines, then SBB-RESULT {"ok": [...], "failed": [[pkg, why]]}."""
++ ok, failed = [], []
++ pkgs = _pkgmap(cfg)
++ if kind == "fix-current":
++ for pkg, path in sorted(pkgs.items()):
++ info = _read(os.path.join(cfg["repo_dir"], path, pkg + ".info")) or ""
++ deps = [d for d in cfg["phantom_deps"] if d in hint_tokens(info, "REQUIRES")]
++ if not deps:
++ continue
++ try:
++ _, target = _hint_target(cfg, pkg, pkgs)
++ text = merge_delrequires(_read(target) or "", deps)
++ if text is not None:
++ _save(target, text)
++ print(f"{pkg}: DELREQUIRES {' '.join(deps)}", flush=True)
++ ok.append(pkg)
++ except (Error, OSError) as e:
++ failed.append([pkg, str(e)])
++ elif kind in ("hint-update", "hint-new"):
++ pairs, no_dl = hint_args(cfg, kind, list(args))
++ for pkg, ver in pairs:
++ try:
++ path, target = _hint_target(cfg, pkg, pkgs)
++ info = _read(os.path.join(cfg["repo_dir"], path, pkg + ".info")) or ""
++ text = _read(target)
++ if text is None:
++ text = new_hint(info, cfg["phantom_deps"])
++ _save(target, apply_version(text, info, ver, no_dl,
++ lambda s, p=pkg: print(f"{p}: {s}", flush=True)))
++ print(f"{pkg}: ok", flush=True)
++ ok.append(pkg)
++ except (Error, OSError) as e:
++ print(f"{pkg}: failed: {e}", flush=True)
++ failed.append([pkg, str(e)])
++ else:
++ raise Error(f"unknown hint job: {kind}")
++ print("SBB-RESULT " + json.dumps({"ok": ok, "failed": failed}), flush=True)
++ if failed:
++ raise Error(f"{len(failed)} failed")
++
++
++def cmd_hint_diff(cfg, pkg=None):
++ _, target = _hint_target(cfg, pkg)
++ bak, current = _read(target + ".bak"), _read(target)
++ if bak is None and current is None:
++ raise Error("no backup")
++ return {"bak": bak, "current": current}
++
++
++def cmd_hint_revert(cfg, pkg=None):
++ _, target = _hint_target(cfg, pkg)
++ if os.path.exists(target + ".bak"):
++ os.replace(target + ".bak", target)
++ elif os.path.exists(target):
++ os.remove(target) # a new hint: reverting it is deleting it
++ else:
++ raise Error("nothing to revert")
++ return {"ok": True}
++
++
++def cmd_hint_delete(cfg, pkg=None):
++ _, target = _hint_target(cfg, pkg)
++ if not os.path.exists(target):
++ raise Error(f"no hint for {pkg}")
++ os.replace(target, target + ".bak")
++ return {"ok": True}
++
++
++def cmd_job_result(cfg, jid=None):
++ """The SBB-RESULT of a hint job's log."""
++ text, _ = _tail(os.path.join(job_dir(cfg, jid), "log"), LOG_TAIL)
++ for line in reversed(text.splitlines()):
++ if line.startswith("SBB-RESULT "):
++ return json.loads(line.split(" ", 1)[1])
++ raise Error(f"no result in {jid}")
++
++
+ # ── signing key ─────────────────────────────────────────────────────────────
+
+ def _signing_key(cfg):
+@@ -626,7 +923,12 @@ COMMANDS = {
+ "build-log": cmd_build_log,
+ "gpg-status": cmd_gpg_status,
+ "gpg-unlock": cmd_gpg_unlock,
++ "hint-diff": cmd_hint_diff,
++ "hint-revert": cmd_hint_revert,
++ "hint-delete": cmd_hint_delete,
++ "job-result": cmd_job_result,
+ "_run": cmd_run,
++ "_hint": cmd_hint,
+ }
+
+
+diff --git c/sb_builder/__init__.py i/sb_builder/__init__.py
+index e0f577e..21a93ab 100644
+--- c/sb_builder/__init__.py
++++ i/sb_builder/__init__.py
+@@ -16,4 +16,4 @@
+
+ """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM."""
+
+-VERSION = "0.4.1" # must match remote/sbb-remote VERSION
++VERSION = "0.5.0" # must match remote/sbb-remote VERSION
+```
+
+- [ ] **Step 4: Run the suite, expect pass.** Run `python3 -m unittest discover -s tests`. Expected: `OK` (104 tests at this point).
+
+- [ ] **Step 5: Commit.**
+
+```bash
+git add remote/sbb-remote remote/config.example.toml sb_builder/__init__.py tests/test_remote.py
+git commit -m "sbb-remote 0.5.0: hint jobs replace mkhint's hint editing
+
+hint-update, hint-new and fix-current run the helper's own _hint command:
+version bumps as mkhint does them (also '-' and dotless URL forms, never
+inside MD5SUM values), md5 of the first URL of each DOWNLOAD pair, --no-dl
+as DOWNLOAD=\"no\", .bak before each write and a final SBB-RESULT line
+that job-result returns. hint-diff, hint-revert and hint-delete answer at
+once. neovim and openvino are refused until phase 4b.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+### Task 2: upstream check on the desktop
+
+**Files:**
+- Create: `nvchecker.toml`, `sb_builder/upstream.py`
+- Modify: `sb_builder/config.py`, `config.example.toml`
+- Test: `tests/test_upstream.py`
+
+- [ ] **Step 1: Write the failing tests.** Save the diff below as `/tmp/t2_test.diff`, then `git apply /tmp/t2_test.diff`.
+
+```diff
+diff --git c/tests/test_upstream.py i/tests/test_upstream.py
+new file mode 100644
+index 0000000..38cba54
+--- /dev/null
++++ i/tests/test_upstream.py
+@@ -0,0 +1,112 @@
++# sb-builder - self-checks for the nvchecker glue
++# 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.
++#
++# You should have received a copy of the GNU General Public License along
++# with this program; if not, write to the Free Software Foundation, Inc.,
++# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
++
++"""Run: python3 -m unittest discover -s tests (no network: nvchecker is faked)"""
++
++import os
++os.environ["QT_QPA_PLATFORM"] = "offscreen"
++
++import shutil # noqa: E402
++import sys # noqa: E402
++import tempfile # noqa: E402
++import time # noqa: E402
++import tomllib # noqa: E402
++import unittest # noqa: E402
++from unittest import mock # noqa: E402
++
++ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
++sys.path.insert(0, ROOT)
++
++from PyQt6.QtWidgets import QApplication # noqa: E402
++
++from sb_builder import upstream # noqa: E402
++
++app = QApplication.instance() or QApplication([])
++LOG = "\n".join([
++ '{"name": "borgbackup", "version": "1.4.5", "event": "updated", "level": "info"}',
++ '{"name": "bogus", "event": "unexpected error happened", "error": "HTTPError(404)", "level": "error"}',
++ '{"name": "bogus", "event": "no-result", "error": "later", "level": "error"}',
++ 'not json',
++])
++
++
++def row(name, hint=None, info="1.0"):
++ return {"name": name, "hint_version": hint, "info_version": info}
++
++
++class UpstreamTest(unittest.TestCase):
++ def setUp(self):
++ self.tmp = tempfile.mkdtemp()
++ self.addCleanup(shutil.rmtree, self.tmp)
++ self.stanzas = os.path.join(self.tmp, "stanzas.toml")
++ with open(self.stanzas, "w") as f:
++ f.write('[x]\nsource = "manual"\nmanual = "2.0"\n')
++
++ def test_repo_file(self):
++ with open(upstream.STANZAS, "rb") as f:
++ self.assertNotIn("__config__", tomllib.load(f))
++ self.assertIn("borgbackup", upstream.stanzas())
++ self.assertEqual(upstream.stanzas("/nonexistent"), set())
++
++ def test_write_config(self):
++ keys = os.path.join(self.tmp, "keys.toml")
++ out = upstream.write_config(self.tmp, keys, self.stanzas)
++ with open(out, "rb") as f:
++ self.assertEqual(tomllib.load(f), {"__config__": {}, "x": {"source": "manual", "manual": "2.0"}})
++ open(keys, "w").close()
++ with open(upstream.write_config(self.tmp, keys, self.stanzas), "rb") as f:
++ self.assertEqual(tomllib.load(f)["__config__"], {"keyfile": keys})
++
++ def test_parse_log(self):
++ self.assertEqual(upstream.parse_log(LOG), ({"borgbackup": "1.4.5"}, {"bogus": "HTTPError(404)"}))
++
++ def test_state(self):
++ names = {"a", "b", "c"}
++ v = {"a": "2.0", "b": "1.0"}
++ self.assertEqual(upstream.state(row("z"), names, v, {}), "no stanza")
++ self.assertEqual(upstream.state(row("a"), names, None, {}), "not checked")
++ self.assertEqual(upstream.state(row("a"), names, v, {}), "outdated")
++ self.assertEqual(upstream.state(row("a", hint="2.0"), names, v, {}), "current")
++ self.assertEqual(upstream.state(row("b"), names, v, {}), "current") # .info version fallback
++ self.assertEqual(upstream.state(row("c"), names, v, {}), "nvchecker error")
++
++ def test_checker_runs_nvchecker(self):
++ bindir = os.path.join(self.tmp, "bin")
++ os.mkdir(bindir)
++ with open(os.path.join(bindir, "nvchecker"), "w") as f:
++ f.write('#!/bin/sh\n[ "$1" = -c ] && [ -f "$2" ] && [ "$3 $4" = "--logger json" ] || exit 9\n'
++ 'echo \'{"name": "x", "version": "2.0", "level": "info"}\'\n')
++ os.chmod(os.path.join(bindir, "nvchecker"), 0o755)
++ with mock.patch.dict(os.environ, {"PATH": bindir + os.pathsep + os.environ["PATH"]}):
++ got = self.run_checker()
++ self.assertEqual(got, [({"x": "2.0"}, {}, "")])
++
++ def test_checker_missing_program(self):
++ with mock.patch.dict(os.environ, {"PATH": self.tmp}):
++ got = self.run_checker()
++ self.assertEqual(len(got), 1)
++ self.assertTrue(got[0][2])
++
++ def run_checker(self):
++ c = upstream.Checker()
++ self.addCleanup(c.deleteLater)
++ got = []
++ c.done.connect(lambda *a: got.append(a))
++ c.run(os.path.join(self.tmp, "nokeys.toml"), self.stanzas)
++ end = time.monotonic() + 5
++ while not got and time.monotonic() < end:
++ app.processEvents()
++ return got
+```
+
+- [ ] **Step 2: Run them, expect failure.** Run `python3 -m unittest tests.test_upstream`. Expected: `ImportError: cannot import name 'upstream' from 'sb_builder'`.
+
+- [ ] **Step 3: Create `nvchecker.toml`** from the VM's config, dropping its `[__config__]` (the first four lines and the blank line after them):
+
+```bash
+{ printf '# sb-builder upstream versions: one nvchecker stanza per package, named after it.\n# Edited by hand. The GUI prepends its own [__config__] (keyfile) when it runs\n# nvchecker; do not add one here. Sources: https://nvchecker.readthedocs.io\n\n'
+ ssh buildsystem cat /root/.config/nvchecker/nvchecker.toml | sed '1,/^keyfile/d' | sed '1{/^$/d}'; } > nvchecker.toml
+python3 -c "import tomllib; d = tomllib.load(open('nvchecker.toml', 'rb')); print(len(d), '__config__' in d)"
+```
+
+Expected: `64 False`.
+
+- [ ] **Step 4: Implement.** Save the diff below as `/tmp/t2_code.diff`, then `git apply /tmp/t2_code.diff`.
+
+```diff
+diff --git c/config.example.toml i/config.example.toml
+index d5fd2c3..e192a28 100644
+--- c/config.example.toml
++++ i/config.example.toml
+@@ -20,3 +20,6 @@ autostart = false # true: start the VM without asking when it is off
+
+ [builds]
+ publish = true # initial state of the "publish" box next to Run it
++
++[hints]
++nvchecker_keyfile = "~/.config/nvchecker/keys.toml" # GitHub token for nvchecker; optional
+diff --git c/sb_builder/config.py i/sb_builder/config.py
+index da1d388..8164f75 100644
+--- c/sb_builder/config.py
++++ i/sb_builder/config.py
+@@ -26,6 +26,7 @@ DEFAULTS = {
+ "notify": {"enabled": True, "events": ["finished", "failed", "killed", "lost", "vm-up"]},
+ "vm": {"libvirt_uri": "qemu:///system", "domain": "buildsystem", "autostart": False},
+ "builds": {"publish": True},
++ "hints": {"nvchecker_keyfile": "~/.config/nvchecker/keys.toml"},
+ }
+
+
+diff --git c/sb_builder/upstream.py i/sb_builder/upstream.py
+new file mode 100644
+index 0000000..d936706
+--- /dev/null
++++ i/sb_builder/upstream.py
+@@ -0,0 +1,109 @@
++# sb-builder - upstream versions through nvchecker
++# 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.
++#
++# You should have received a copy of the GNU General Public License along
++# with this program; if not, write to the Free Software Foundation, Inc.,
++# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
++
++"""Upstream versions: nvchecker run on the desktop over this repo's nvchecker.toml.
++
++No oldver/newver files: nvchecker's JSON log on stdout carries each version,
++and "outdated" is decided against the hints, not against a previous run.
++"""
++
++import json
++import os
++import tempfile
++import tomllib
++
++from PyQt6.QtCore import QObject, QProcess, pyqtSignal
++
++STANZAS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "nvchecker.toml")
++
++
++def stanzas(path=STANZAS):
++ """Package names with a stanza; an unreadable file has none."""
++ try:
++ with open(path, "rb") as f:
++ return set(tomllib.load(f)) - {"__config__"}
++ except (OSError, tomllib.TOMLDecodeError):
++ return set()
++
++
++def write_config(tmpdir, keyfile, path=STANZAS):
++ """This run's config: a [__config__] with the keyfile (when present), then the stanzas."""
++ head = "[__config__]\n"
++ if os.path.isfile(keyfile):
++ head += f"keyfile = {json.dumps(keyfile)}\n" # a JSON string is a TOML basic string
++ with open(path) as f:
++ body = f.read()
++ out = os.path.join(tmpdir, "nvchecker.toml")
++ with open(out, "w") as f:
++ f.write(head + "\n" + body)
++ return out
++
++
++def parse_log(text):
++ """({name: version}, {name: error}) from nvchecker --logger json output."""
++ versions, errors = {}, {}
++ for line in text.splitlines():
++ try:
++ e = json.loads(line)
++ except ValueError:
++ continue
++ if not isinstance(e, dict) or "name" not in e:
++ continue
++ if e.get("version"):
++ versions[e["name"]] = e["version"]
++ elif e.get("level") == "error":
++ errors.setdefault(e["name"], str(e.get("error") or e.get("event")))
++ return versions, errors
++
++
++def state(row, names, versions, errors):
++ """Hints screen state of an inventory row; versions None = not checked yet."""
++ name = row["name"]
++ if name not in names:
++ return "no stanza"
++ if versions is None:
++ return "not checked"
++ if name not in versions:
++ return "nvchecker error"
++ return "current" if versions[name] == (row["hint_version"] or row["info_version"]) else "outdated"
++
++
++class Checker(QObject):
++ """One nvchecker run at a time; done(versions, errors, failure text or "")."""
++
++ done = pyqtSignal(dict, dict, str)
++
++ def __init__(self, parent=None):
++ super().__init__(parent)
++ self.proc = None
++
++ def run(self, keyfile, path=STANZAS):
++ tmp = tempfile.TemporaryDirectory()
++ p = self.proc = QProcess(self)
++
++ def finished(rc=-1, _status=None):
++ out = bytes(p.readAllStandardOutput()).decode(errors="replace")
++ err = bytes(p.readAllStandardError()).decode(errors="replace").strip()
++ tmp.cleanup()
++ self.proc = None
++ p.deleteLater()
++ versions, errors = parse_log(out)
++ self.done.emit(versions, errors, "" if rc == 0 else err or f"nvchecker: exit {rc}")
++
++ p.finished.connect(finished)
++ p.errorOccurred.connect(
++ lambda e: e == QProcess.ProcessError.FailedToStart and finished(-1))
++ p.start("nvchecker", ["-c", write_config(tmp.name, keyfile, path), "--logger", "json"])
+```
+
+- [ ] **Step 5: Run the suite, expect pass.** Run `python3 -m unittest discover -s tests`. Expected: `OK` (110 tests).
+
+- [ ] **Step 6: Commit.**
+
+```bash
+git add nvchecker.toml sb_builder/upstream.py sb_builder/config.py config.example.toml tests/test_upstream.py
+git commit -m "Upstream versions from nvchecker on the desktop
+
+nvchecker.toml moves from the VM into the repo. The GUI runs nvchecker
+with its own [__config__] (the keyfile from [hints]) and reads versions
+and per-package errors from --logger json; outdated is decided against
+the hints, so no oldver, newver or nvtake.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+### Task 3: Hints screen, result dialog, Packages pane
+
+**Files:**
+- Create: `sb_builder/hints_tab.py`
+- Modify: `sb_builder/mainwindow.py`, `sb_builder/packages.py`
+- Test: `tests/test_gui.py`
+
+- [ ] **Step 1: Write the failing tests.** Save the diff below as `/tmp/t3_test.diff`, then `git apply /tmp/t3_test.diff`.
+
+```diff
+diff --git c/tests/test_gui.py i/tests/test_gui.py
+index 0da4243..aa9c03b 100644
+--- c/tests/test_gui.py
++++ i/tests/test_gui.py
+@@ -450,13 +450,82 @@ class SmokeTest(unittest.TestCase):
+ w, _ = self.window()
+ w.poll_jobs()
+ self.assertEqual([a.text() for a in w.ribbon.actions()],
+- ["Dashboard", "Packages", "Results", "Jobs"])
++ ["Dashboard", "Packages", "Hints", "Results", "Jobs"])
+ self.assertEqual([m.text() for m in w.menuBar().actions()], ["&File"])
+ w.ribbon.actions()[1].trigger()
+ self.assertIs(w.tabs.currentWidget(), w.packages_tab)
+ w.ribbon.actions()[0].trigger()
+ self.assertIs(w.tabs.currentWidget(), w.dashboard)
+
++ def test_hints_tab(self):
++ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-update"}, None),
++ "hint-delete": ({"ok": True}, None)})
++ w.poll_jobs()
++ tab = w.open_hints()
++ with tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) as f:
++ f.write('[nuclei]\nsource = "manual"\n[curl]\nsource = "manual"\n')
++ self.addCleanup(os.unlink, f.name)
++ tab.stanzas = f.name
++ tab.reload()
++ self.assertEqual(sorted(r["name"] for r in tab.rows), ["curl", "foo", "nuclei"]) # foo: hint only
++ states = {tab.table.item(i, 1).text(): tab.table.item(i, 5).text() for i in range(3)}
++ self.assertEqual(states, {"curl": "not checked", "foo": "no stanza", "nuclei": "not checked"})
++ tab._checked({"nuclei": "3.12.0", "curl": "1.0"}, {}, "")
++ self.assertEqual(tab.checked(), ["nuclei"]) # outdated rows come checked
++ tab.chips["current"].setChecked(True)
++ visible = [tab.table.item(i, 1).text() for i in range(3) if not tab.table.isRowHidden(i)]
++ self.assertEqual(visible, ["curl"])
++ tab.no_dl.setChecked(True)
++ tab.update_selected()
++ self.assertIn(("job-start", "hint-update", "nuclei=3.12.0", "--no-dl"), remote.calls)
++ tab._checked({}, {"curl": "HTTPError(404)"}, "")
++ row = next(i for i in range(3) if tab.table.item(i, 1).text() == "curl")
++ self.assertEqual(tab.table.item(row, 5).toolTip(), "HTTPError(404)")
++ self.assertIs(w.open_hints("foo"), tab)
++ self.assertEqual(tab.current(), "foo")
++
++ def test_hint_job_end_opens_diff(self):
++ job = dict(JOB, id="20260101-000000-hint-update", kind="hint-update", args=["nuclei=3.12.0"])
++ w, remote = self.window(jobs=({"jobs": [job]}, None),
++ **{"job-result": ({"ok": ["nuclei"], "failed": [["curl", "HTTP 404"]]}, None),
++ "hint-diff": ({"bak": 'VERSION="3.11.1"\n', "current": 'VERSION="3.12.0"\n'},
++ None),
++ "hint-revert": ({"ok": True}, None),
++ "job-start": ({"id": "20260101-000001-slackrepo"}, None)})
++ w.poll_jobs()
++ remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=5, exit=1)]}, None)
++ w.poll_jobs()
++ self.assertEqual(len(w.hint_dialogs), 1)
++ d = w.hint_dialogs[0]
++ self.assertIn('-VERSION="3.11.1"\n+VERSION="3.12.0"', d.diff.toPlainText())
++ d.revert()
++ d.revert() # a second revert must not reach the helper: it would delete the hint
++ self.assertEqual([c for c in remote.calls if c[0] == "hint-revert"], [("hint-revert", "nuclei")])
++ self.assertEqual(d.list.item(0).text(), "nuclei (reverted)")
++ d.build() # nothing left to build
++ self.assertFalse(any(c[0] == "job-start" for c in remote.calls))
++ self.assertEqual(w.hint_dialogs, [])
++ w.show_hint_result({"ok": ["nuclei"], "failed": []})
++ w.hint_dialogs[0].build()
++ self.assertIn(("job-start", "slackrepo", "update", "--preview", "nuclei"), remote.calls)
++
++ def test_packages_new_hint(self):
++ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-new"}, None)})
++ w.poll_jobs()
++ tab = w.open_packages()
++ tab.table.setCurrentIndex(tab.proxy.index(1, 1)) # foo, has a hint
++ self.assertFalse(tab.new_hint_btn.isVisibleTo(tab))
++ tab.open_hint_btn.click()
++ self.assertIs(w.tabs.currentWidget(), w.hints_tab)
++ tab.table.setCurrentIndex(tab.proxy.index(3, 1)) # curl, no hint
++ self.assertTrue(tab.new_hint_btn.isVisibleTo(tab))
++ tab.ask_new_hint = lambda name: None
++ tab.new_hint()
++ self.assertFalse(any(c[0] == "job-start" for c in remote.calls))
++ tab.ask_new_hint = lambda name: ("8.0", True)
++ tab.new_hint()
++ self.assertIn(("job-start", "hint-new", "curl", "8.0", "--no-dl"), remote.calls)
++
+ def test_tab_shortcuts_wrap(self):
+ w, _ = self.window()
+ w.poll_jobs()
+```
+
+- [ ] **Step 2: Run them, expect failures.** Run `python3 -m unittest tests.test_gui`. Expected: `AttributeError: 'MainWindow' object has no attribute 'open_hints'` and the ribbon list mismatch.
+
+- [ ] **Step 3: Implement.** Save the diff below as `/tmp/t3_code.diff`, then `git apply /tmp/t3_code.diff`.
+
+```diff
+diff --git c/sb_builder/hints_tab.py i/sb_builder/hints_tab.py
+new file mode 100644
+index 0000000..abe6eb9
+--- /dev/null
++++ i/sb_builder/hints_tab.py
+@@ -0,0 +1,283 @@
++# sb-builder - Hints tab
++# 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.
++#
++# You should have received a copy of the GNU General Public License along
++# with this program; if not, write to the Free Software Foundation, Inc.,
++# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
++
++"""Hints tab: upstream check, hint jobs, and the diff/revert dialog after them."""
++
++import difflib
++import os
++
++from PyQt6.QtCore import Qt, pyqtSignal
++from PyQt6.QtGui import QColor, QFontDatabase
++from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
++ QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSplitter,
++ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget)
++
++from . import upstream
++from .dashboard import STATUS
++from .packages import chip, load_rows
++
++STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error")
++STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"],
++ "nvchecker error": STATUS["failed"]}
++COLUMNS = ("", "package", "hint", ".info", "upstream", "state")
++HINT_JOBS = ("hint-update", "hint-new", "fix-current")
++
++
++class HintsTab(QWidget):
++ start_requested = pyqtSignal(list) # job-start arguments
++
++ def __init__(self, remote, cfg, stanzas=upstream.STANZAS, parent=None):
++ super().__init__(parent)
++ self.remote, self.cfg, self.stanzas = remote, cfg, stanzas
++ self.rows = []
++ self.names = upstream.stanzas(stanzas)
++ self.versions = None # {pkg: version} after Check upstream
++ self.errors, self.failure = {}, ""
++ self.checker = upstream.Checker(self)
++ self.checker.done.connect(self._checked)
++
++ self.query = QLineEdit(placeholderText="search package")
++ self.query.textChanged.connect(self.refilter)
++ self.chips = {}
++ chips = QHBoxLayout()
++ for name in STATES:
++ self.chips[name] = chip(name)
++ self.chips[name].toggled.connect(self.refilter)
++ chips.addWidget(self.chips[name])
++ chips.addStretch()
++ self.refresh_btn = QPushButton("Refresh")
++ self.refresh_btn.clicked.connect(self.reload)
++ chips.addWidget(self.refresh_btn)
++
++ self.table = QTableWidget(0, len(COLUMNS))
++ self.table.setHorizontalHeaderLabels(COLUMNS)
++ self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
++ self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
++ self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
++ self.table.verticalHeader().hide()
++ self.table.horizontalHeader().setStretchLastSection(True)
++ for col, width in enumerate((28, 220, 110, 110, 110)):
++ self.table.setColumnWidth(col, width)
++
++ self.status = QLabel()
++ self.check_btn = QPushButton("Check upstream")
++ self.check_btn.clicked.connect(self.check)
++ self.update_btn = QPushButton("Update selected")
++ self.update_btn.clicked.connect(self.update_selected)
++ self.no_dl = QCheckBox("no download")
++ self.no_dl.setToolTip('Write DOWNLOAD="no": slackrepo uses the source already in its cache')
++ bar = QHBoxLayout()
++ bar.addWidget(self.status)
++ bar.addStretch()
++ for w in (self.check_btn, self.update_btn, self.no_dl):
++ bar.addWidget(w)
++ for label, slot in (("Fix current", self.fix_current), ("Delete hint", self.delete)):
++ b = QPushButton(label)
++ b.clicked.connect(slot)
++ bar.addWidget(b)
++
++ layout = QVBoxLayout(self)
++ layout.addWidget(self.query)
++ layout.addLayout(chips)
++ layout.addWidget(self.table)
++ layout.addLayout(bar)
++
++ def reload(self):
++ self.status.setText("loading…")
++ self.remote.call(self._loaded, "inventory")
++
++ def _loaded(self, data, err):
++ if err:
++ self.status.setText(f"inventory failed: {err}")
++ return
++ self.names = upstream.stanzas(self.stanzas) # re-read: the file is edited by hand
++ self.rows = [r for r in load_rows(data) if r["hint_path"] or r["name"] in self.names]
++ self._fill()
++
++ def _fill(self, check_outdated=False):
++ checked = self.checked() if not check_outdated else []
++ current = self.current()
++ self.table.setSortingEnabled(False)
++ self.table.setRowCount(len(self.rows))
++ for i, r in enumerate(self.rows):
++ r["state"] = upstream.state(r, self.names, self.versions, self.errors)
++ box = QTableWidgetItem()
++ box.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable)
++ on = r["state"] == "outdated" if check_outdated else r["name"] in checked
++ box.setCheckState(Qt.CheckState.Checked if on else Qt.CheckState.Unchecked)
++ box.setData(Qt.ItemDataRole.UserRole, r["name"])
++ self.table.setItem(i, 0, box)
++ up = (self.versions or {}).get(r["name"], "")
++ for col, text in enumerate((r["name"], r["hint_version"] or "", r["info_version"] or "",
++ up, r["state"]), 1):
++ self.table.setItem(i, col, QTableWidgetItem(text))
++ state = self.table.item(i, 5)
++ if r["state"] in STATE_COLOR:
++ state.setForeground(QColor(STATE_COLOR[r["state"]]))
++ if r["state"] == "nvchecker error":
++ state.setToolTip(self.errors.get(r["name"]) or self.failure or "no result")
++ self.table.setSortingEnabled(True)
++ if current:
++ self.select(current)
++ self.refilter()
++
++ def refilter(self):
++ on = {n for n, b in self.chips.items() if b.isChecked()}
++ text = self.query.text().lower()
++ shown = 0
++ for i in range(self.table.rowCount()):
++ name, state = self.table.item(i, 1).text(), self.table.item(i, 5).text()
++ hide = (on and state not in on) or text not in name.lower()
++ self.table.setRowHidden(i, bool(hide))
++ shown += not hide
++ n = len(self.checked())
++ self.status.setText(f"{shown} shown · {n} selected" + (f" · {self.failure}" if self.failure else ""))
++
++ def checked(self):
++ return [self.table.item(i, 0).data(Qt.ItemDataRole.UserRole)
++ for i in range(self.table.rowCount())
++ if self.table.item(i, 0).checkState() == Qt.CheckState.Checked]
++
++ def current(self):
++ i = self.table.currentRow()
++ return self.table.item(i, 1).text() if i >= 0 and self.table.item(i, 1) else None
++
++ def select(self, name):
++ for i in range(self.table.rowCount()):
++ if self.table.item(i, 1).text() == name:
++ self.table.setRowHidden(i, False)
++ self.table.selectRow(i)
++ return
++
++ def check(self):
++ self.check_btn.setEnabled(False)
++ self.status.setText("checking upstream…")
++ self.checker.run(os.path.expanduser(self.cfg["hints"]["nvchecker_keyfile"]), self.stanzas)
++
++ def _checked(self, versions, errors, failure):
++ self.check_btn.setEnabled(True)
++ self.versions, self.errors, self.failure = versions, errors, failure
++ self._fill(check_outdated=True)
++
++ def update_selected(self):
++ versions = self.versions or {}
++ pairs = [f"{n}={versions[n]}" for n in self.checked() if n in versions]
++ if pairs:
++ self.start_requested.emit(["hint-update", *pairs] + (["--no-dl"] if self.no_dl.isChecked() else []))
++
++ def fix_current(self):
++ if QMessageBox.question(self, "Fix current", "Add DELREQUIRES for the -current phantom deps "
++ "to every package that needs them?") == QMessageBox.StandardButton.Yes:
++ self.start_requested.emit(["fix-current"])
++
++ def delete(self):
++ name = self.current()
++ if name and QMessageBox.question(self, "Delete hint", f"Delete the hint of {name}? "
++ "(kept as .bak, Revert restores it)") \
++ == QMessageBox.StandardButton.Yes:
++ self.remote.call(self._changed, "hint-delete", name)
++
++ def _changed(self, _data, err):
++ if err:
++ QMessageBox.warning(self, "Hint", err)
++ self.reload()
++
++
++class HintResultDialog(QDialog):
++ """After a hint job: each changed package's diff, Revert per package, Build these."""
++
++ build_requested = pyqtSignal(list) # package names
++ changed = pyqtSignal()
++
++ def __init__(self, remote, result, parent=None):
++ super().__init__(parent)
++ self.remote = remote
++ self.setWindowTitle("Hint changes")
++ self.resize(900, 600)
++ self.list = QListWidget()
++ for pkg in result["ok"]:
++ item = QListWidgetItem(pkg)
++ item.setData(Qt.ItemDataRole.UserRole, pkg)
++ self.list.addItem(item)
++ self.list.currentItemChanged.connect(self._show)
++ self.diff = QPlainTextEdit(readOnly=True)
++ self.diff.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
++ split = QSplitter()
++ split.addWidget(self.list)
++ split.addWidget(self.diff)
++ split.setSizes([250, 650])
++ failed = QLabel("\n".join(f"{p}: {why}" for p, why in result["failed"]))
++ failed.setStyleSheet(f"color: {STATUS['failed']}")
++ failed.setVisible(bool(result["failed"]))
++ self.revert_btn = QPushButton("Revert")
++ self.revert_btn.clicked.connect(self.revert)
++ build = QPushButton("Build these")
++ build.clicked.connect(self.build)
++ close = QPushButton("Close")
++ close.clicked.connect(self.accept)
++ bar = QHBoxLayout()
++ bar.addWidget(self.revert_btn)
++ bar.addStretch()
++ bar.addWidget(build)
++ bar.addWidget(close)
++ layout = QVBoxLayout(self)
++ layout.addWidget(split, 1)
++ layout.addWidget(failed)
++ layout.addLayout(bar)
++ if self.list.count():
++ self.list.setCurrentRow(0) # the diff of each package is fetched when selected
++
++ def _show(self, item, _prev):
++ self.diff.clear()
++ if item:
++ pkg = item.data(Qt.ItemDataRole.UserRole)
++ self.remote.call(lambda d, err: self._diff(pkg, d, err), "hint-diff", pkg)
++
++ def _diff(self, pkg, data, err):
++ item = self.list.currentItem()
++ if not item or item.data(Qt.ItemDataRole.UserRole) != pkg:
++ return # another package was selected meanwhile
++ if err:
++ self.diff.setPlainText(err)
++ return
++ old = (data["bak"] or "").splitlines(keepends=True)
++ new = (data["current"] or "").splitlines(keepends=True)
++ self.diff.setPlainText("".join(difflib.unified_diff(
++ old, new, f"{pkg}.hint.bak" if data["bak"] is not None else "/dev/null",
++ f"{pkg}.hint" if data["current"] is not None else "/dev/null")) or "no change")
++
++ def revert(self):
++ item = self.list.currentItem()
++ if not item or item.data(Qt.ItemDataRole.UserRole + 1):
++ return # a second revert would delete the restored hint
++ pkg = item.data(Qt.ItemDataRole.UserRole)
++
++ def done(_data, err):
++ if err:
++ QMessageBox.warning(self, "Revert", err)
++ return
++ item.setText(f"{pkg} (reverted)")
++ item.setData(Qt.ItemDataRole.UserRole + 1, True)
++ self.diff.setPlainText("reverted")
++ self.changed.emit()
++ self.remote.call(done, "hint-revert", pkg)
++
++ def build(self):
++ pkgs = [self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count())
++ if not self.list.item(i).data(Qt.ItemDataRole.UserRole + 1)]
++ if pkgs:
++ self.build_requested.emit(pkgs)
++ self.accept()
+diff --git c/sb_builder/mainwindow.py i/sb_builder/mainwindow.py
+index 247b0cf..0331eb9 100644
+--- c/sb_builder/mainwindow.py
++++ i/sb_builder/mainwindow.py
+@@ -27,6 +27,7 @@ from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QSty
+ from . import VERSION, config
+ from .dashboard import Dashboard
+ from .fmt import job_title
++from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab
+ from .jobs import JobsTab
+ from .packages import PackagesTab
+ from .results import ResultsTab
+@@ -37,6 +38,7 @@ NOTIFY_PATH = "/org/freedesktop/Notifications"
+ # ribbon entries: label, freedesktop icon name, Qt fallback icon
+ RIBBON = (("Dashboard", "go-home", QStyle.StandardPixmap.SP_DirHomeIcon),
+ ("Packages", "package-x-generic", QStyle.StandardPixmap.SP_FileDialogDetailedView),
++ ("Hints", "document-edit", QStyle.StandardPixmap.SP_FileDialogContentsView),
+ ("Results", "view-list-text", QStyle.StandardPixmap.SP_MessageBoxWarning),
+ ("Jobs", "system-run", QStyle.StandardPixmap.SP_MediaPlay))
+ # tabs as a distinct, centered row; colors from the palette so the theme applies
+@@ -87,6 +89,8 @@ class MainWindow(QMainWindow):
+ self.jobs_tab = None
+ self.packages_tab = None
+ self.results_tab = None
++ self.hints_tab = None
++ self.hint_dialogs = [] # open HintResultDialogs, kept alive while shown
+ self.seen = None # job id -> status at the last poll; None before the first
+ self.online = None # None until the first answer
+ self.overlays = []
+@@ -119,7 +123,8 @@ class MainWindow(QMainWindow):
+ self.ribbon.setIconSize(QSize(32, 32))
+ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.ribbon)
+ slots = {"Dashboard": lambda: self.tabs.setCurrentWidget(self.dashboard),
+- "Packages": self.open_packages, "Results": lambda: self.open_results(),
++ "Packages": self.open_packages, "Hints": lambda: self.open_hints(),
++ "Results": lambda: self.open_results(),
+ "Jobs": self.open_jobs}
+ for label, name, fallback in RIBBON:
+ icon = QIcon.fromTheme(name, self.style().standardIcon(fallback))
+@@ -281,9 +286,27 @@ class MainWindow(QMainWindow):
+ if event and prev.get(j["id"], "running") != j["status"]:
+ self.notify(event, job_title(j), j["id"])
+ if j["kind"] in ("slackrepo", "publish"): # build state changed
+- for tab in (self.packages_tab, self.results_tab):
+- if tab:
+- tab.reload()
++ self._reload(self.packages_tab, self.results_tab)
++ if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"):
++ self._reload(self.packages_tab, self.hints_tab)
++ self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"])
++
++ @staticmethod
++ def _reload(*tabs):
++ for tab in tabs:
++ if tab:
++ tab.reload()
++
++ def show_hint_result(self, result):
++ if not result["ok"] and not result["failed"]:
++ return # nothing changed, nothing failed: no news
++ d = HintResultDialog(self.remote, result, self)
++ d.build_requested.connect(
++ lambda pkgs: self.open_jobs().start("slackrepo", "update", "--preview", *pkgs))
++ d.changed.connect(lambda: self._reload(self.packages_tab, self.hints_tab))
++ d.finished.connect(lambda _r: self.hint_dialogs.remove(d))
++ self.hint_dialogs.append(d)
++ d.show()
+
+ # ── tabs ────────────────────────────────────────────────────────────────
+
+@@ -304,11 +327,23 @@ class MainWindow(QMainWindow):
+ if self.packages_tab is None:
+ self.packages_tab = PackagesTab(self.remote)
+ self.packages_tab.start_requested.connect(lambda args: self.open_jobs().start(*args))
++ self.packages_tab.hints_requested.connect(self.open_hints)
+ self.tabs.addTab(self.packages_tab, "Packages")
+ self.packages_tab.reload()
+ self.tabs.setCurrentWidget(self.packages_tab)
+ return self.packages_tab
+
++ def open_hints(self, name=None):
++ if self.hints_tab is None:
++ self.hints_tab = HintsTab(self.remote, self.cfg)
++ self.hints_tab.start_requested.connect(lambda args: self.open_jobs().start(*args))
++ self.tabs.addTab(self.hints_tab, "Hints")
++ self.hints_tab.reload()
++ if name:
++ self.hints_tab.select(name)
++ self.tabs.setCurrentWidget(self.hints_tab)
++ return self.hints_tab
++
+ def open_results(self, result=None):
+ if self.results_tab is None:
+ self.results_tab = ResultsTab(self.remote)
+@@ -338,6 +373,8 @@ class MainWindow(QMainWindow):
+ self.packages_tab = None
+ if w is self.results_tab:
+ self.results_tab = None
++ if w is self.hints_tab:
++ self.hints_tab = None
+ w.deleteLater()
+
+ def reload_config(self):
+diff --git c/sb_builder/packages.py i/sb_builder/packages.py
+index 34ce7f5..146db51 100644
+--- c/sb_builder/packages.py
++++ i/sb_builder/packages.py
+@@ -20,8 +20,9 @@ import html
+
+ from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal
+ from PyQt6.QtGui import QColor
+-from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton,
+- QScrollArea, QSplitter, QTableView, QVBoxLayout, QWidget)
++from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
++ QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter,
++ QTableView, QVBoxLayout, QWidget)
+
+ from . import hints as hintlib
+ from .dashboard import STATUS
+@@ -209,6 +210,7 @@ def table(rows):
+
+ class PackagesTab(QWidget):
+ start_requested = pyqtSignal(list) # job-start arguments
++ hints_requested = pyqtSignal(str) # package to show in the Hints tab
+
+ def __init__(self, remote, parent=None):
+ super().__init__(parent)
+@@ -245,8 +247,22 @@ class PackagesTab(QWidget):
+ alignment=Qt.AlignmentFlag.AlignTop)
+ self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ self.detail.setContentsMargins(12, 8, 12, 8)
++ self.new_hint_btn = QPushButton("New hint")
++ self.new_hint_btn.clicked.connect(self.new_hint)
++ self.open_hint_btn = QPushButton("Open in Hints")
++ self.open_hint_btn.clicked.connect(lambda: self.hints_requested.emit(self.current()["name"]))
++ detail_bar = QHBoxLayout()
++ detail_bar.addWidget(self.new_hint_btn)
++ detail_bar.addWidget(self.open_hint_btn)
++ detail_bar.addStretch()
++ pane = QWidget()
++ pane_layout = QVBoxLayout(pane)
++ pane_layout.addWidget(self.detail)
++ pane_layout.addLayout(detail_bar)
++ pane_layout.addStretch()
+ scroll = QScrollArea(widgetResizable=True)
+- scroll.setWidget(self.detail)
++ scroll.setWidget(pane)
++ self.show_detail(QModelIndex(), None)
+ split = QSplitter()
+ split.addWidget(self.table)
+ split.addWidget(scroll)
+@@ -327,6 +343,37 @@ class PackagesTab(QWidget):
+ == QMessageBox.StandardButton.Yes:
+ self.start_requested.emit(["publish"])
+
++ def current(self):
++ index = self.table.currentIndex()
++ return index.data(Qt.ItemDataRole.UserRole) if index.isValid() else None
++
+ def show_detail(self, current, _previous):
+ row = current.data(Qt.ItemDataRole.UserRole) if current.isValid() else None
+ self.detail.setText(detail_html(row) if row else "")
++ self.new_hint_btn.setVisible(bool(row) and not row["hint_path"])
++ self.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"]))
++
++ def ask_new_hint(self, name):
++ """(version or "", no download) for a new hint, None on cancel."""
++ d = QDialog(self, windowTitle=f"New hint: {name}")
++ version = QLineEdit(placeholderText="the .info version")
++ no_dl = QCheckBox('no download (DOWNLOAD="no")')
++ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok
++ | QDialogButtonBox.StandardButton.Cancel)
++ buttons.accepted.connect(d.accept)
++ buttons.rejected.connect(d.reject)
++ form = QFormLayout(d)
++ form.addRow("version", version)
++ form.addRow(no_dl)
++ form.addRow(buttons)
++ if d.exec() != QDialog.DialogCode.Accepted:
++ return None
++ return version.text().strip(), no_dl.isChecked()
++
++ def new_hint(self):
++ row = self.current()
++ answer = row and self.ask_new_hint(row["name"])
++ if answer:
++ version, no_dl = answer
++ self.start_requested.emit(["hint-new", row["name"], *([version] if version else []),
++ *(["--no-dl"] if no_dl else [])])
+```
+
+- [ ] **Step 4: Run the suite, expect pass.** Run `python3 -m unittest discover -s tests`. Expected: `OK` (113 tests).
+
+- [ ] **Step 5: Look at it.** Render the screens offscreen and read the PNGs (checked while writing this plan: table, chips, button bar, diff dialog with the failure line under the split, New hint under the detail pane):
+
+```bash
+QT_QPA_PLATFORM=offscreen python3 - <<'EOF'
+import sys; sys.path.insert(0, "tests"); sys.argv = ["x"]
+import test_gui as t
+from sb_builder.mainwindow import MainWindow
+from sb_builder import config
+r = t.FakeRemote({"version": ({"version": t.VERSION, "overlays": []}, None), "stats": (t.STATS, None),
+ "jobs": ({"jobs": []}, None), "gpg-status": ({"unlocked": True, "key": "K"}, None),
+ "inventory": (t.INVENTORY, None),
+ "hint-diff": ({"bak": 'VERSION="3.11.1"\n', "current": 'VERSION="3.12.0"\n'}, None)})
+w = MainWindow(config.load("/nonexistent")[0], r, t.FakeNotifier()); w.resize(1100, 700); w.poll_jobs()
+w.open_hints()._checked({"nuclei": "3.12.0"}, {}, "")
+w.show(); t.app.processEvents(); w.grab().save("/tmp/hints.png")
+w.show_hint_result({"ok": ["nuclei"], "failed": [["curl", "HTTP Error 404: Not Found"]]})
+t.app.processEvents(); w.hint_dialogs[0].grab().save("/tmp/dialog.png")
+EOF
+```
+
+- [ ] **Step 6: Commit.**
+
+```bash
+git add sb_builder/hints_tab.py sb_builder/mainwindow.py sb_builder/packages.py tests/test_gui.py
+git commit -m "Hints screen: upstream check, hint jobs, diff and revert
+
+Check upstream fills the upstream column and ticks the outdated hints,
+Update selected starts hint-update, Fix current and Delete hint act on
+the VM. When a hint job ends its SBB-RESULT opens a dialog with each
+changed hint's diff, Revert per package (once) and Build these. The
+Packages detail pane gets New hint and Open in Hints.
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+### Task 4: docs
+
+**Files:** `README.md`, `AGENTS.md`, `docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md`
+
+The spec edits record what writing the code found: nvchecker needs no
+newver and reports through `--logger json`; DELREQUIRES-only hints are
+pinned from the `.info`; failure leaves `.bak` alone; `update` builds
+unbuilt items; 64 stanzas, not 65; pinned hints do carry
+`DOWNLOAD_x86_64`.
+
+- [ ] **Step 1: Apply.** Save the diff below as `/tmp/t4.diff`, then `git apply /tmp/t4.diff`.
+
+```diff
+diff --git c/AGENTS.md i/AGENTS.md
+index 9113e34..eda2dd8 100644
+--- c/AGENTS.md
++++ i/AGENTS.md
+@@ -17,7 +17,8 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is
+
+ - Every GUI action is `ssh <target> sbb-remote <cmd> [args]`; the helper
+ answers with JSON. `sb_builder/remote.py` is the only code that runs ssh
+- (and local `virsh`).
++ (and local `virsh`). `sb_builder/upstream.py` runs the local `nvchecker`
++ over the repo's `nvchecker.toml`; the VM never sees the GitHub token.
+ - Trust boundary: the helper runs only a fixed table of job kinds and builds
+ argv itself from validated args. Never pass shell strings or paths from the
+ GUI to the helper.
+@@ -32,10 +33,15 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is
+ - Hints are parsed (`shlex`), never sourced. `sb_builder/hints.py` mirrors
+ slackrepo's own rules (`../GITHUB/slackrepo/libexec/functions.d/parsefunctions.sh`);
+ check that file when changing it.
++- Hint editing lives in the helper (`hint-update`, `hint-new`, `fix-current`
++ jobs run the helper's own `_hint` command): text edits, `.bak` before each
++ write, a final `SBB-RESULT {json}` log line read back with `job-result`.
++ It replaces mkhint, which stays on the VM only for neovim and openvino
++ (bundled deps, `manifest_packages`) until phase 4b.
+ - Jobs never get input. Any tool run as a job must be non-interactive
+ (`--yes`/`--no` style flags). If a tool needs a flag it lacks, add it in
+- that tool's own repo (e.g. `slackrepo_setup` for sbshadow, `mkhintfile`
+- for mkhint) rather than working around it here.
++ that tool's own repo (e.g. `slackrepo_setup` for sbshadow) rather than
++ working around it here.
+
+ ## Conventions
+
+diff --git c/README.md i/README.md
+index e6434c9..63ebd1a 100644
+--- c/README.md
++++ i/README.md
+@@ -1,8 +1,8 @@
+ # sb-builder
+
+ Desktop GUI for managing a Slackware SlackBuilds build VM from one place. It
+-runs slackrepo builds, overlay syncs (sbpull, sbshadow), hintfile work
+-(mkhint) and SBo orphan discovery on the VM over ssh, as background jobs that
++runs slackrepo builds, overlay syncs (sbpull, sbshadow), hint file updates
++and SBo orphan discovery on the VM over ssh, as background jobs that
+ keep running when the GUI disconnects and can be reattached later. A
+ dashboard shows VM and repository state, and the GUI can start the VM through
+ libvirt when it is off.
+@@ -12,10 +12,9 @@ Work in progress. See `docs/superpowers/specs/` for the design.
+ ## Requirements
+
+ - Desktop: Python 3.11 or newer, PyQt6 (with QtDBus), ssh access to the VM,
+- optionally `virsh` for starting the VM
++ optionally `virsh` for starting the VM and `nvchecker` for upstream checks
+ - VM: Python 3.11 or newer, root ssh login, the
+ [slackrepo_setup](https://forge.slackware.nl/danix/slackrepo_setup) tools (`sbpull`, `sbshadow`)
+- and mkhint
+
+ ## Install
+
+@@ -58,6 +57,20 @@ Every key is optional. `File > Reload config` rereads the GUI config.
+ the package's last attempt (slackrepo keeps one per command: `build.log`,
+ `update.log`, `rebuild.log`, `remove.log`), where it reports the failure,
+ with its `resource.log` (load, memory, tmp over the build).
++- **Hints** (ribbon): every package with a hint or an nvchecker stanza, with
++ hint, `.info` and upstream version. **Check upstream** runs nvchecker on the
++ desktop over `nvchecker.toml` in this repo (stanza name = package name, edit
++ it by hand to track a package; `hints.nvchecker_keyfile` gives it a GitHub
++ token) and ticks the outdated packages. **Update selected** rewrites their
++ hints on the VM (version, URLs, md5 of the downloaded source; **no download**
++ writes `DOWNLOAD="no"` instead). **Fix current** adds `DELREQUIRES` for the
++ -current phantom deps (`phantom_deps` in the VM config). **Delete hint**
++ keeps a `.bak`. The Packages detail pane has **New hint** for a package
++ without one.
++- After a hint job a dialog shows the diff of each changed hint, with
++ **Revert** per package and **Build these** (a slackrepo update preview).
++ neovim and openvino are refused for now: their bundled deps still need
++ mkhint on the VM.
+ - Real runs sign the repository. When the package signing key is locked
+ (status bar 🔒), the GUI asks for its passphrase first and hands it to the
+ VM's gpg-agent; it is never stored.
+diff --git c/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md i/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md
+index df360eb..4b659c7 100644
+--- c/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md
++++ i/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md
+@@ -1,7 +1,7 @@
+ # sb-builder phase 4: hints
+
+ Date: 2026-10-20
+-Status: draft
++Status: approved
+ Builds on: `2026-10-19-sb-builder-phase1-design.md` (helper, job model, GUI shell),
+ `2026-10-19-sb-builder-phase2-design.md` (inventory, hint view, slackrepo jobs)
+
+@@ -25,7 +25,9 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`,
+ - The VM has 607 hints, all in `hintdir`, none in subdirectories and none in
+ the SBo tree. 594 carry `DELREQUIRES` (from `mkhint -F`), 13 carry
+ `VERSION` + `ARCH` + `DOWNLOAD` + `MD5SUM`, one carries `OPTIONS`. No hint
+- carries `NODOWNLOAD` or a `DOWNLOAD_x86_64`/`MD5SUM_x86_64` line.
++ carries `NODOWNLOAD`. Pinned hints carry `DOWNLOAD_x86_64`/`MD5SUM_x86_64`
++ lines, mostly empty (`claude-code`: `DOWNLOAD="UNSUPPORTED"` with the URL in
++ `DOWNLOAD_x86_64`), and `ARCH="x86_64"`.
+ - slackrepo reads `DOWNLOAD="no"` as "no download" and ignores `NODOWNLOAD`.
+ `DOWNLOAD_$ARCH` replaces `DOWNLOAD` when set
+ (`libexec/functions.d/parsefunctions.sh:457`), so `DOWNLOAD="no"` next to a
+@@ -45,11 +47,17 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`,
+ phantom dep, merge those deps into the hint's `DELREQUIRES` (union, first
+ seen order, `.bak` only when something changes, minimal hint created when
+ absent).
+-- The VM's nvchecker config has 65 stanzas named after the packages, plus a
++- The VM's nvchecker config has 64 stanzas named after the packages, plus a
+ `[__config__]` with oldver, newver and keyfile. nvchecker v2.22 is
+ installed on the desktop, with a GitHub token in
+- `~/.config/nvchecker/keys.toml`. Its newver file is
+- `{"version": 2, "data": {"<name>": {"version": "..."}, ...}}`.
++ `~/.config/nvchecker/keys.toml`. With `--logger json` it prints one JSON
++ object per line on stdout: `{"name", "version", "event": "updated"}` per
++ result, `{"name", "error", "level": "error"}` per failure; it exits 0 when
++ entries fail and needs no oldver or newver to run.
++- A DELREQUIRES-only hint has no `VERSION`; mkhint's update cannot bump it.
++ Updating one pins it from the `.info` first.
++- slackrepo `update <item>` builds an item that exists, built or not
++ (`update_command` calls `build_command`).
+ - The phantom deps list on the VM (`/root/.config/mkhint/phantom-deps`) has
+ 18 entries.
+ - Two packages use bundled-dep manifests: neovim (url mode) and openvino
+@@ -70,20 +78,22 @@ editing, migration of `NODOWNLOAD=yes` lines (none exist).
+
+ ## Part 1: upstream check, on the desktop
+
+-- `nvchecker.toml` in the repo root: the VM's 65 stanzas, without
++- `nvchecker.toml` in the repo root: the VM's 64 stanzas, without
+ `[__config__]`. Stanza name = package name. Git-tracked, edited by hand.
+ - `sb_builder/upstream.py` runs nvchecker through `QProcess` (as `remote.py`
+- runs `virsh`) with a fixed argv: `nvchecker -c <tmp>/nvchecker.toml`. The
+- temp dir holds a generated config: a `[__config__]` with `keyfile` (from
+- the GUI config) and `newver = "<tmp>/new.json"`, followed by the repo
+- file's text. No oldver, no `nvtake`: the comparison is against the hints.
+-- Result: `{pkg: version}` from the newver file. Failure: the exit status and
+- stderr, shown as a `nvchecker error` state.
++ runs `virsh`) with a fixed argv: `nvchecker -c <tmp>/nvchecker.toml
++ --logger json`. The temp dir holds a generated config: a `[__config__]`
++ with `keyfile` (from the GUI config, when the file exists), followed by the
++ repo file's text. No oldver, newver or `nvtake`: the comparison is against
++ the hints.
++- Result: `{pkg: version}` and `{pkg: error}` from the JSON log. A package
++ without a result, or a failed run (exit status, stderr), shows as
++ `nvchecker error` with the reason in the tooltip.
+ - A package is outdated when its upstream version differs from the hint
+ `VERSION`, or from the `.info` `VERSION` when the hint has none. Versions
+ are compared as strings; no ordering is attempted.
+
+-GUI config gains `nvchecker_keyfile`, default
++GUI config gains `[hints] nvchecker_keyfile`, default
+ `~/.config/nvchecker/keys.toml` (expanded). A missing keyfile is not an
+ error: nvchecker runs without a token.
+
+@@ -108,27 +118,38 @@ the 18 entries at deploy. `remote/config.example.toml` shows the key.
+
+ ### Job kinds
+
+-- `hint-update <pkg>=<ver>... [--no-dl]`: per package, in order: back up the
+- hint to `.bak`, apply the version (below), write atomically. A failure
+- restores the hint from the `.bak` it just wrote, logs `<pkg>: failed: <why>`
+- and continues with the next package. The last log line is
+- `SBB-RESULT {"ok": [...], "failed": [...]}`. Exit status 1 when any
+- package failed.
++- `hint-update <pkg>=<ver>... [--no-dl]`: per package, in order: compute the
++ new text (apply the version, below), then back the hint up to `.bak` and
++ write atomically. A failure (download, missing `.info` VERSION) leaves the
++ hint and its old `.bak` untouched, logs `<pkg>: failed: <why>` and
++ continues with the next package. A package without a hint gets a new one
++ built as `hint-new` does. The last log line is
++ `SBB-RESULT {"ok": [pkg...], "failed": [[pkg, why]...]}`. Exit status 1
++ when any package failed.
+ - `hint-new <pkg> [<ver>] [--no-dl]`: refuses when the hint exists. Builds
+ the hint from the `.info` as mkhint does, phantom deps from
+- `phantom_deps`, then applies the version when given. No `.bak` (nothing to
+- back up); revert means delete.
++ `phantom_deps`, then applies the version when given. No `.bak` (a stale
++ one is removed); revert means delete.
+ - `fix-current`: mkhint's `-F` with `phantom_deps`. Logs one line per
+ changed hint, then `SBB-RESULT {"ok": [...], "failed": []}`.
+
+-These jobs never sign, so they do not check the signing key.
++These jobs never sign, so they do not check the signing key. Their argv is
++the helper itself: `python3 sbb-remote _hint <kind> <args>`, validated again
++there. Job ids now allow a hyphenated kind (`...-hint-update`).
++
++`job-result <id>`: the last `SBB-RESULT` object of a job's log.
+
+ ### Applying a version
+
+-1. Old version = hint `VERSION`, else `.info` `VERSION`.
+-2. Replace old by new everywhere in the file. When either contains `_`, also
+- the `-` forms. When old contains `.`, also its dotless form inside URLs,
+- as a whole number (no digit before or after), so md5 hex is untouched.
++1. A hint without `VERSION` is first pinned from the `.info`: its
++ `VERSION`, `DOWNLOAD*` and `MD5SUM*` lines, plus `ARCH="x86_64"` when the
++ hint has no `ARCH`, are put in front of the hint's text. Old version = the
++ hint's `VERSION`. With no version given (`hint-new <pkg>`), steps 2 and 3
++ are skipped.
++2. Replace old by new everywhere in the file except inside `MD5SUM*`
++ values. When either contains `_`, also the `-` forms. When old contains
++ `.`, also its dotless form inside URLs, as a whole number (no digit before
++ or after).
+ 3. Without `--no-dl`: for `DOWNLOAD`/`MD5SUM` and
+ `DOWNLOAD_x86_64`/`MD5SUM_x86_64`, split the values on whitespace (hint
+ values may continue over lines with `\`). Skip a pair whose value is empty,
+@@ -141,9 +162,8 @@ These jobs never sign, so they do not check the signing key.
+ written, because `DOWNLOAD_$ARCH` would override it.
+
+ Hint text is edited as text, not regenerated, so comments, order and other
+-keys stay as they are. Assignments are located with the rules the helper's
+-`parse_assignments` already uses (`KEY="..."` at line start, values may span
+-lines inside the quotes).
++keys stay as they are. An assignment is `KEY="..."` (the value may span
++lines inside the quotes) or `KEY=word`, at line start.
+
+ ### Commands (answer at once, not jobs)
+
+@@ -187,9 +207,10 @@ When a `hint-update`, `hint-new` or `fix-current` job ends, the GUI reads
+ its `SBB-RESULT` line and opens a dialog with one entry per changed package:
+ a unified diff (`difflib`) of `hint-diff`, and a **Revert** button per
+ package. Failed packages are listed with their reason. The dialog ends with
+-**Build these**, which starts the usual slackrepo preview for the changed
+-packages (`update` when built, `build` otherwise, as the Packages tab
+-decides), and **Close**. After Revert or Delete the inventory is reloaded.
++**Build these**, which starts `slackrepo update --preview` for the changed
++packages that were not reverted (update builds unbuilt items too), and
++**Close**. Revert is offered once per package: a second one would delete
++the restored hint. After Revert or Delete the inventory is reloaded.
+
+ For `fix-current` the dialog lists the packages without opening every diff
+ (it can touch hundreds); clicking one shows its diff.
+@@ -224,8 +245,9 @@ For `fix-current` the dialog lists the packages without opening every diff
+ changes nothing and writes no `.bak`), new -> revert, update -> diff ->
+ revert, delete -> revert, manifest package refusal, unmanaged hint path
+ refusal, bad version and package rejection.
+-- `test_upstream.py`: generated config text, newver parsing from a fixture,
+- outdated comparison (hint version, `.info` fallback). No network.
++- `test_upstream.py`: generated config, JSON log parsing, states (hint
++ version, `.info` fallback), the repo file has no `[__config__]`, `Checker`
++ against a fake `nvchecker` on `PATH` and a missing one. No network.
+ - `test_gui.py`: Hints screen with a fake remote and injected upstream
+ results (states, checked rows, Update selected argv), diff dialog Revert
+ call.
+```
+
+- [ ] **Step 2: Commit and push.**
+
+```bash
+git add README.md AGENTS.md docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md
+git commit -m "Document the Hints screen; phase 4 spec from the implementation
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+git push
+git log --format='%h %G? %s' -5 # every line G
+```
+
+### Task 5: deploy (ask the user first) and check on the VM
+
+- [ ] **Step 1: Ask**, then deploy the helper: `./install.sh --remote`. Expected last line: `{"version": "0.5.0", ...}`.
+- [ ] **Step 2: Phantom deps into the helper config.** Build the line locally (a JSON array of strings is a TOML array), show it and the VM config to the user, then append it:
+
+```bash
+line=$(ssh buildsystem cat /root/.config/mkhint/phantom-deps | python3 -c '
+import json, sys
+deps = [l.split("#")[0].strip() for l in sys.stdin]
+print("phantom_deps = " + json.dumps([d for d in deps if d]))')
+echo "$line"; ssh buildsystem cat /etc/sb-builder/config.toml
+printf '%s\n' "$line" | ssh buildsystem 'cat >> /etc/sb-builder/config.toml && sbb-remote version'
+```
+
+Expected: an 18-entry array, then the version JSON (a config error would print `{"error": ...}`).
+- [ ] **Step 3: Read-only check on real data:** `ssh buildsystem sbb-remote hint-diff flatpak` returns `{"bak": null, "current": "VERSION=..."}` (or the `.bak` mkhint left).
+- [ ] **Step 4: User's manual run** in the GUI, reported back: Hints > Check upstream fills the column; Update selected on one outdated package, dialog shows the diff, Revert restores it; Update again and Build these opens the slackrepo preview; Fix current reports no changes (`mkhint -F` already ran); New hint and Delete hint on a throwaway package, each reverted.