diff --git a/.claude/skills/preview-verdicts/SKILL.md b/.claude/skills/preview-verdicts/SKILL.md index ce363d06..fbae4003 100644 --- a/.claude/skills/preview-verdicts/SKILL.md +++ b/.claude/skills/preview-verdicts/SKILL.md @@ -9,21 +9,22 @@ Maintainer review happens through an interactive HTML page: one row per entry wi ## Generate the preview -1. Build the `DATA` array. A group is `[section, subcategory, rows]`; a row is `[entry, url, downloads, verdict, reason]`, plus an optional sixth `source` field. +1. Build the `DATA` array. A group is `[section, subcategory, rows]`; a row is `[entry, url, downloads, kind, verdict, reason]`, plus an optional seventh `source` field. - `source` says where a row came from when that is not obvious, such as the starred repo behind a scan candidate or an incumbent pulled in during the audit. The page shows a Source column only when a row has one. - `subcategory` may carry a note after ` — ` (rendered muted): use it for proposed splits, re-homes, or anything the maintainer should weigh for the whole group. - `downloads` is PyPI last-month as a comma-formatted string; use `—` when no signal exists (e.g. agent skill packs), `stdlib` for standard-library modules, `fetch failed` when the lookup failed. State the fetch date in the sub-header. - - `verdict` is seeded from the current adjudication or dry-run, and its row kind sets the buttons, so each verdict word names one outcome: - - Listed entry: `keep` or `remove` (taken out of README.md). - - Proposed new entry: `add` or `reject` (not added, logged under "Rejected Additions"). - - Structure question: `restructure` (take the change the reason or group note describes), `park` (no record, so it resurfaces), or `reject` (logged under "Rejected Additions"); seed it `restructure` or `park`. - - Show a replacement as two rows: the old entry `remove`, the new entry `add`. + - `kind` sets the buttons independently of the seeded `verdict`: + - `listed`: `keep`, `remove` (taken out of README.md), or `park`. + - `candidate`: `add`, `reject` (not added, logged under "Rejected Additions"), or `park`. + - `structure`: `restructure` (take the change the reason or group note describes), `park`, or `reject` (logged under "Rejected Additions"). + - Seed `verdict` from the current adjudication or dry-run. `park` defers a decision for a later review; preserve the row's `kind` so its original choices remain available. + - Show a replacement as two rows: the old entry `listed` / `remove`, the new entry `candidate` / `add`. - `reason` is plain language the maintainer reads cold — no invented shorthand. When fresh evidence contradicts the seeded verdict (a big download count on a remove or reject, a dead repo on a keep), say so in that row's reason instead of silently changing the seed. 2. Copy `template.html` (sibling of this file) and replace the placeholders: `__TITLE__` (page title), `__SUB__` (sub-header: scope, seed provenance, fetch date, and the standing instruction to flip/comment then Copy feedback), `__KEY__` (localStorage key), `__DATA__` (the array). `__KEY__` must be unique per review — slug plus date, e.g. `awesome-python-science-2026-09-01` — because saved state under a reused key bleeds a previous review's flips into rows with the same section and entry name. 3. Write the page to `local/awesome-python--preview.html` in the repo root (git-ignored; create the directory if needed), `open` it, and tell the maintainer the path and the return path: flip or comment rows (they highlight yellow), press **Copy feedback**, paste the JSON into the chat. Done when the page is open and the return path is stated. ## Process the pasted feedback -Each JSON row is `{section, subcategory, entry, my_verdict, your_verdict, reason}`, with verdict words as defined above. The maintainer's verdict is final — apply it, never re-argue it. An empty reason means the verdict stands unexplained; that is enough. +Each JSON row is `{section, subcategory, entry, kind, my_verdict, your_verdict, reason}`, with kinds and verdicts as defined above. The maintainer's verdict is final — apply it, never re-argue it. An empty reason means the verdict stands unexplained; that is enough. Before executing, surface anything the flips imply that the maintainer has not decided: a use case pushed past its cap, an entry left homeless by a proposed split, a request that is already satisfied (a no-op). Ask, then execute on their go. Done when every pasted row is either applied or surfaced back — none silently dropped. diff --git a/.claude/skills/preview-verdicts/template.html b/.claude/skills/preview-verdicts/template.html index 1eb3e405..d71b01f7 100644 --- a/.claude/skills/preview-verdicts/template.html +++ b/.claude/skills/preview-verdicts/template.html @@ -74,8 +74,7 @@ const KEY = "__KEY__"; let state = {}; try { state = JSON.parse(localStorage.getItem(KEY) || "{}"); } catch (e) {} -// A row's seeded verdict picks its button set, so each verdict word names one outcome. -const CHOICES = { keep: ["keep", "remove"], remove: ["keep", "remove"], add: ["add", "reject"], reject: ["add", "reject"], restructure: ["restructure", "park", "reject"], park: ["restructure", "park", "reject"] }; +const CHOICES = { listed: ["keep", "remove", "park"], candidate: ["add", "reject", "park"], structure: ["restructure", "park", "reject"] }; const cap = v => v[0].toUpperCase() + v.slice(1); function rowId(section, name) { return section + "::" + name; } @@ -83,13 +82,13 @@ function rowId(section, name) { return section + "::" + name; } function render() { const app = document.getElementById("app"); let html = "", lastSection = ""; - const hasSource = DATA.some(g => g[2].some(r => r.length > 5)); + const hasSource = DATA.some(g => g[2].some(r => r.length > 6)); for (const [section, subcat, rows] of DATA) { if (section !== lastSection) { html += `

${section}

`; lastSection = section; } const parts = subcat.split(" — "); html += `

${parts[0]}${parts[1] ? ` — ${parts[1]}` : ""}

`; html += `${hasSource ? "" : ""}`; - for (const [name, url, dl, mine, reason, source] of rows) { + for (const [name, url, dl, kind, mine, reason, source] of rows) { const id = rowId(section, name); const s = state[id] || {}; const yours = s.verdict || mine; @@ -101,7 +100,7 @@ function render() { `; @@ -125,19 +124,19 @@ function setF(id, f) { updateCounts(); } function findMine(section, name) { - for (const [s, , rows] of DATA) if (s === section) for (const r of rows) if (r[0] === name) return r[3]; + for (const [s, , rows] of DATA) if (s === section) for (const r of rows) if (r[0] === name) return r[4]; } function save() { localStorage.setItem(KEY, JSON.stringify(state)); } function collect() { const out = []; for (const [section, subcat, rows] of DATA) { - for (const [name, , , mine] of rows) { + for (const [name, , , kind, mine] of rows) { const id = rowId(section, name); const s = state[id] || {}; const yours = s.verdict || mine; const fb = (s.feedback || "").trim(); - if (yours !== mine || fb) out.push({ section, subcategory: subcat.split(" — ")[0], entry: name, my_verdict: mine, your_verdict: yours, reason: fb }); + if (yours !== mine || fb) out.push({ section, subcategory: subcat.split(" — ")[0], entry: name, kind, my_verdict: mine, your_verdict: yours, reason: fb }); } } return out; @@ -146,7 +145,7 @@ function collect() { function updateCounts() { const tally = {}; let changed = 0; - for (const [section, , rows] of DATA) for (const [name, , , mine] of rows) { + for (const [section, , rows] of DATA) for (const [name, , , , mine] of rows) { const s = state[rowId(section, name)] || {}; const yours = s.verdict || mine; tally[yours] = (tally[yours] || 0) + 1; diff --git a/.claude/skills/scan-github-stars/SKILL.md b/.claude/skills/scan-github-stars/SKILL.md index 3b019986..e2622f18 100644 --- a/.claude/skills/scan-github-stars/SKILL.md +++ b/.claude/skills/scan-github-stars/SKILL.md @@ -25,7 +25,7 @@ Group survivors by target use case. Per group, invoke `audit-the-list` with: "Ju ## 4. Review -Run `preview-verdicts` over all groups: survivors as `add` or `reject` rows, displaced entries as `remove`, structure questions as `park` or `restructure` rows, and any `prior` verdict quoted in the row's reason. Seed `park` on any row whose verdict rests on a judgment call, except an add where only the tier is in doubt, so the page holds only settled calls. Done when the maintainer's feedback is processed and they gave the go. +Run `preview-verdicts` over all groups: survivors with kind `candidate` and verdict `add` or `reject`, displaced entries with kind `listed` and verdict `remove`, and structure questions with kind `structure` and verdict `park` or `restructure`. Quote any `prior` verdict in the reason. Seed `park` on any row whose verdict rests on a judgment call, except an add where only the tier is in doubt; preserve its kind. Done when the maintainer's feedback is processed and they gave the go. ## 5. Record diff --git a/website/tests/test_preview_verdicts.py b/website/tests/test_preview_verdicts.py new file mode 100644 index 00000000..08c5674d --- /dev/null +++ b/website/tests/test_preview_verdicts.py @@ -0,0 +1,108 @@ +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +TEMPLATE = Path(__file__).resolve().parents[2] / ".claude/skills/preview-verdicts/template.html" +NODE = shutil.which("node") + +RUNNER = r""" +const fs = require("node:fs"); +const vm = require("node:vm"); +const { script, actions } = JSON.parse(fs.readFileSync(0, "utf8")); +const storage = {}; +let elements, context, copied; +function reload() { + elements = {}; + context = vm.createContext({ + document: { getElementById: id => elements[id] ||= {}, title: "Review" }, + localStorage: { getItem: key => storage[key], setItem: (key, value) => storage[key] = value }, + navigator: { clipboard: { writeText: text => { copied = text; return Promise.resolve(); } } } + }); + vm.runInContext(script, context); +} +function snapshot() { + return { + html: elements.app.innerHTML, + counts: elements.counts.innerHTML, + feedback: context.collect(), + rowClass: elements["r-Section__entry"]?.className, + copied + }; +} +reload(); +const snapshots = [snapshot()]; +for (const [action, ...args] of actions) { + if (action === "reload") reload(); + else context[action](...args); + snapshots.push(snapshot()); +} +process.stdout.write(JSON.stringify(snapshots)); +""" + + +def preview(kind, verdict, actions=(), source=None): + if NODE is None: + pytest.skip("Preview script tests require Node.js") + row = ["entry", "https://github.com/owner/entry", "1,000", kind, verdict, "Review reason"] + if source is not None: + row.append(source) + data = [["Section", "Use case — review note", [row]]] + script = TEMPLATE.read_text().split("", 1)[0].replace("__DATA__", json.dumps(data)) + result = subprocess.run([NODE, "-e", RUNNER], input=json.dumps({"script": script, "actions": actions}), capture_output=True, text=True, check=True) + return json.loads(result.stdout) + + +@pytest.mark.parametrize( + ("kind", "accept", "reject", "buttons"), + [ + ("listed", "keep", "remove", ["Keep", "Remove", "Park"]), + ("candidate", "add", "reject", ["Add", "Reject", "Park"]), + ("structure", "restructure", "reject", ["Restructure", "Park", "Reject"]), + ], +) +def test_parked_rows_keep_their_choices_and_feedback_after_reload(kind, accept, reject, buttons): + initial, accepted, reloaded, copied, rejected, parked = preview( + kind, + "park", + [("setV", "Section::entry", accept), ("reload",), ("copyFeedback",), ("setV", "Section::entry", reject), ("setV", "Section::entry", "park")], + ) + for snapshot in (initial, accepted, reloaded, copied, rejected, parked): + assert re.findall(r"]*>([^<]+)", snapshot["html"]) == buttons + assert initial["feedback"] == parked["feedback"] == [] + expected = [{"section": "Section", "subcategory": "Use case", "entry": "entry", "kind": kind, "my_verdict": "park", "your_verdict": accept, "reason": ""}] + assert accepted["feedback"] == reloaded["feedback"] == expected + assert json.loads(copied["copied"]) == expected + assert rejected["feedback"][0]["your_verdict"] == reject + assert f'" in with_source + assert '' in with_source
EntrySourceDownloads/moMy verdictMy reasonYoursYour reason (optional)
${mine.toUpperCase()} ${reason} - ${CHOICES[mine].map(v => ``).join("")} + ${CHOICES[kind].map(v => ``).join("")}
SourceStarred engine