aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-20 20:30:57 +0200
committerDanilo M. <danix@danix.xyz>2026-10-20 20:30:57 +0200
commit0602623806b7c0984544913d663804ed5f54e07f (patch)
treefdd838b21d4dd0775f98760ca11d9e91e92d3745 /docs/superpowers/plans/2026-10-20-sb-builder-phase4.md
parentcd9ca18cf12ab24e0d1532d01c0f621b06869545 (diff)
downloadsb-builder-0602623806b7c0984544913d663804ed5f54e07f.tar.gz
sb-builder-0602623806b7c0984544913d663804ed5f54e07f.zip
Docs ready to publish: VM setup, specs in docs/designHEADmaster
README says what the GUI does today and what is planned, which defaults are the author's setup, and how to set up the VM. The specs move to docs/design/; the implementation plans are dropped (working files, now kept out of git). AGENTS.md links slackrepo upstream instead of a local clone path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers/plans/2026-10-20-sb-builder-phase4.md')
-rw-r--r--docs/superpowers/plans/2026-10-20-sb-builder-phase4.md1994
1 files changed, 0 insertions, 1994 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
deleted file mode 100644
index 1d15e1f..0000000
--- a/docs/superpowers/plans/2026-10-20-sb-builder-phase4.md
+++ /dev/null
@@ -1,1994 +0,0 @@
-# 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 on the VM through the helper, 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 111 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 key `manifest_packages`, hyphenated job ids, VERSION 0.5.0 |
-| `remote/config.example.toml` | the new key |
-| `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..14622b8 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,255 @@ 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),
-+ '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"\n')
-+ with self.assertRaisesRegex(sbb.Error, "no VERSION"):
-+ sbb.new_hint('PRGNAM="x"\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.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.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")):
-+ 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'
-+ 'DOWNLOAD="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('#REQUIRES="rust-opt a"', self.read(self.hint))
-+ self.assertIn('MD5SUM="old"', self.read(self.hint)) # no version given: no download
-+
-+ 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..a77ace9 100644
---- c/remote/config.example.toml
-+++ i/remote/config.example.toml
-@@ -12,3 +12,4 @@ 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"
-+manifest_packages = ["neovim", "openvino"] # bundled deps: hint-update refuses them (phase 4b)
-diff --git c/remote/sbb-remote i/remote/sbb-remote
-index 25427c0..2674ed6 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,13 @@ DEFAULTS = {
- "default_hintdir": "",
- "genreprc": "/root/.genreprc",
- "slackrepo_logdir": "/var/log/slackrepo/SBo-danix",
-+ "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 +89,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", "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 +143,67 @@ 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):
-+ 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")}}
-
-
- def signs(kind, args):
-@@ -549,6 +613,212 @@ 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):
-+ """mkhint's hint from an .info: the pin, REQUIRES commented, ARCH.
-+
-+ No phantom deps: a package that has them already has a hint, the
-+ DELREQUIRES one sbshadow's mkhint -F writes after every sync.
-+ """
-+ text = _pin(info)
-+ m = _assignment("REQUIRES").search(info)
-+ if m:
-+ text += "#" + m.group(0).rstrip("\n") + "\n"
-+ return text + 'ARCH="x86_64"\n'
-+
-+
-+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]]}."""
-+ if kind not in ("hint-update", "hint-new"):
-+ raise Error(f"unknown hint job: {kind}")
-+ ok, failed = [], []
-+ pkgs = _pkgmap(cfg)
-+ 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)
-+ _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)])
-+ 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 +896,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` (102 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 and hint-new 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` (108 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..6be57fc
---- /dev/null
-+++ i/sb_builder/hints_tab.py
-@@ -0,0 +1,277 @@
-+# 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")
-+
-+
-+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)
-+ delete = QPushButton("Delete hint")
-+ delete.clicked.connect(self.delete)
-+ bar.addWidget(delete)
-+
-+ 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 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` (111 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, Delete hint acts 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..9a8918c 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,16 @@ 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` 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 for neovim and openvino
-+ (bundled deps, `manifest_packages`) and for the `DELREQUIRES` hints
-+ sbshadow regenerates with `mkhint -F` after every sync, 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..e1f93cc 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,19 @@ 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; a hint sbshadow wrote, `DELREQUIRES` only,
-+ gets the `.info` version lines added). **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..c45c720 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,15 +1,15 @@
- # 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)
-
- ## Goal
-
- Manage slackrepo hint files from sb-builder: check upstream versions, update
--version hints, create and delete hints, sweep `DELREQUIRES` for -current
--phantom deps, and see a diff of every change with a way to revert it.
-+version hints, create and delete hints, and see a diff of every change
-+with a way to revert it.
-
- Direction set by the user: the repo is run from the desktop, the VM shell is
- for upgrading Slackware only. The VM-side tools were built for a terminal; a
-@@ -23,9 +23,11 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`,
- 1.4.0, 2291 lines) and in slackrepo (`../GITHUB/slackrepo`):
-
- - 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
-+ the SBo tree. 594 carry only `DELREQUIRES`, 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
-@@ -44,12 +46,24 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`,
- - mkhint's `-F`: for every `.info` in the repo whose `REQUIRES` contains a
- 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
-+ absent). It edits only the `DELREQUIRES` line. sbshadow
-+ (slackrepo_setup) runs it after every sync: that is where the 594
-+ DELREQUIRES-only hints come from, and why they exist (slackrepo must not
-+ build deps -current does not need). Until now sbshadow ran it on `--no`
-+ previews too.
-+- 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; the next `mkhint -F` keeps the
-+ pin. A package with a phantom dep always has such a hint, so `hint-new`
-+ never sees one.
-+- 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
-@@ -57,33 +71,37 @@ Verified on the VM, in the mkhint source (`~/Programming/GIT/mkhintfile/mkhint`,
-
- ## Scope
-
--In: upstream check, version update, new hint, delete hint, fix-current,
-+In: upstream check, version update, new hint, delete hint,
- `--no-dl` as `DOWNLOAD="no"`, diff and revert.
-
- Out, for good (the Hints screen covers them): mkhint `--list`, `--versions`,
- `--review`, `--strip-version`, `--info`, `--clean`.
-
--Out, later: bundled-dep reconcile for neovim and openvino (phase 4b, which
--then removes mkhint and nvchecker from the VM), generating nvchecker stanzas
-+Out, later (phase 4b, which then removes mkhint and nvchecker from the VM):
-+bundled-dep reconcile for neovim and openvino, and a helper `fix-current`
-+that sbshadow calls in place of `mkhint -F`, with the phantom deps list
-+moved into the helper config as its single source. Also out: generating nvchecker stanzas
- for new packages (add them to `nvchecker.toml` by hand), free text hint
- 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.
-
-@@ -91,8 +109,7 @@ error: nvchecker runs without a token.
-
- ### Config
-
--New key `phantom_deps`, list of strings, default `[]`. The VM config gets
--the 18 entries at deploy. `remote/config.example.toml` shows the key.
-+New key `manifest_packages` (below). `remote/config.example.toml` shows it.
-
- ### Arguments
-
-@@ -108,27 +125,36 @@ 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.
--- `fix-current`: mkhint's `-F` with `phantom_deps`. Logs one line per
-- changed hint, then `SBB-RESULT {"ok": [...], "failed": []}`.
-+ the hint from the `.info` as mkhint does, without phantom deps (see the
-+ facts), then applies the version when given. No `.bak` (a stale
-+ one is removed); revert means delete.
-
--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 +167,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)
-
-@@ -177,22 +202,21 @@ convention.
- - **Update selected**: starts `hint-update pkg=ver ...` for the checked
- rows that have an upstream version; a **No download** checkbox adds
- `--no-dl`.
-- - **Fix current**: starts `fix-current`.
- - **Delete hint**: confirm dialog, then `hint-delete` for the current row.
- - Jobs go through the Jobs tab like every other job.
-
- ### After a hint job
-
--When a `hint-update`, `hint-new` or `fix-current` job ends, the GUI reads
-+When a `hint-update` or `hint-new` 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.
-+Each diff is fetched when its package is selected.
-
- ### Packages detail pane
-
-@@ -219,13 +243,13 @@ For `fix-current` the dialog lists the packages without opening every diff
- touched), multi-line `DOWNLOAD`/`MD5SUM` with continuation md5s kept,
- `UNSUPPORTED` skipped, `--no-dl` removing `DOWNLOAD_x86_64` and writing
- `DOWNLOAD="no"`, a download failure restoring the hint while the next
-- package still updates, `SBB-RESULT` contents, `hint-new` from `.info`
-- with phantom deps, `fix-current` merge and idempotence (second run
-- changes nothing and writes no `.bak`), new -> revert, update -> diff ->
-+ package still updates, `SBB-RESULT` contents, `hint-new` from `.info`,
-+ a DELREQUIRES-only hint pinned from the `.info`, 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.
-@@ -233,6 +257,6 @@ For `fix-current` the dialog lists the packages without opening every diff
- ## Deploy (asks first)
-
- 1. `./install.sh --remote` with helper 0.5.0.
--2. Add `phantom_deps` (the VM's 18 entries) to `/etc/sb-builder/config.toml`.
-+2. slackrepo_setup's sbshadow, which no longer runs `mkhint -F` on `--no`.
- 3. mkhint, nvchecker and `/root/.config/{mkhint,nvchecker}` stay on the VM
- until phase 4b.
-```
-
-- [ ] **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: sbshadow.** slackrepo_setup `f5b14ff` (`--no` skips `mkhint -F`): show the user the VM copy's diff, then `scp ~/Programming/GIT/slackrepo_setup/sbshadow buildsystem:$(ssh buildsystem command -v sbshadow)` and `ssh buildsystem 'sbshadow --no | tail -2'`, expecting `sbshadow: preview, phantom-dep hintfiles not regenerated.`
-- [ ] **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; New hint and Delete hint on a throwaway package, each reverted.