diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-23 13:02:47 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-23 13:02:47 -0500 |
| commit | 3f802eb819bc9be572b230ac1bd142b7ce13d87f (patch) | |
| tree | 60be68db7167d064669c608cfb8402812eeae5c2 | |
| parent | 7f2aea1e022c93f3eb463e5222bdb0d8ae6288b9 (diff) | |
| download | rulesets-3f802eb819bc9be572b230ac1bd142b7ce13d87f.tar.gz rulesets-3f802eb819bc9be572b230ac1bd142b7ce13d87f.zip | |
chore(ai): resync workflow and script mirror with canonical
The .ai/ mirror lagged claude-templates/.ai/ for three workflows (task-audit, task-review, triage-intake) and two scripts (screenshot.py and its test) — earlier commits updated the canonical copies without resyncing the mirror in the same commit. The startup rsync caught it up; this commit tracks the result so the two stay identical.
| -rwxr-xr-x | .ai/scripts/screenshot.py | 266 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_screenshot.py | 97 | ||||
| -rw-r--r-- | .ai/workflows/task-audit.org | 1 | ||||
| -rw-r--r-- | .ai/workflows/task-review.org | 9 | ||||
| -rw-r--r-- | .ai/workflows/triage-intake.org | 5 |
5 files changed, 376 insertions, 2 deletions
diff --git a/.ai/scripts/screenshot.py b/.ai/scripts/screenshot.py new file mode 100755 index 0000000..bdff419 --- /dev/null +++ b/.ai/scripts/screenshot.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Capture a screenshot for visual verification (Wayland / Hyprland). + +Claude can read PNG files, so this turns "does the UI look right?" into an +inspectable artifact: capture the screen, a window, or a freshly-launched app, +then read the path it prints. + +Modes (pick one): + --full capture everything (all outputs) [default] + --active capture the currently focused window + --window REGEX capture the window whose class or title matches REGEX + --list list open windows (class / title / workspace) and exit + --launch CMD run CMD on a transient off-screen (headless) output, + capture it, and tear everything down — verifies an app's + UI without touching the visible workspace + +Layout (with --launch): + --layout LAYOUT tiled (window fills output) | monocle (fills, gapless) | + floating (window floats, sized by --size, centered) + --size WxH tiled/monocle: headless output resolution; + floating: window size (e.g. 1600x1000) + --timeout SECONDS how long to wait for the launched window (default 10) + +Common: + --output PATH PNG output path (default: timestamped file in /tmp); + the saved path is printed on stdout, parent dir created + --delay SECONDS wait before capturing (let a window settle / animate) + +Requires grim and hyprctl. For window/region modes the window must be visible +on the active workspace — grim reads rendered outputs, so a hidden window +captures whatever is drawn at its region. The --launch mode sidesteps that by +rendering on a headless output that is real to the compositor but not displayed. +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time +from datetime import datetime +from typing import NoReturn + + +def die(msg) -> NoReturn: + print(f"screenshot: {msg}", file=sys.stderr) + sys.exit(1) + + +# --------------------------- pure helpers (unit-tested) ---------------------- + +def parse_size(size): + """Parse 'WxH' into an (width, height) int tuple. Dies on malformed input.""" + m = re.fullmatch(r"(\d+)x(\d+)", size or "") + if not m: + die(f"--size expects WxH (e.g. 1600x1000), got {size!r}") + return int(m.group(1)), int(m.group(2)) + + +def geometry_str(client): + """Build a grim -g geometry string ('x,y wxh') from a client's at/size.""" + (x, y), (w, h) = client["at"], client["size"] + return f"{x},{y} {w}x{h}" + + +def match_windows(clients, regex): + """Return the CLIENTS whose class or title matches REGEX (case-insensitive).""" + pat = re.compile(regex, re.IGNORECASE) + return [c for c in clients + if pat.search(c.get("class", "") or "") + or pat.search(c.get("title", "") or "")] + + +def launch_rule(wsid, layout): + """Hyprland exec-rule body: place a window on WSID silently, per LAYOUT.""" + rule = f"workspace {wsid} silent" + if layout == "monocle": + rule += ";fullscreen 1" + elif layout == "floating": + rule += ";float" + return rule + + +def center_offset(ow, oh, w, h): + """Top-left offset to center a w*h window in an ow*oh output (clamped >=0).""" + return max(0, (ow - w) // 2), max(0, (oh - h) // 2) + + +# ------------------------------- I/O wrappers -------------------------------- + +def require_tools(*tools): + missing = [t for t in tools if shutil.which(t) is None] + if missing: + die(f"missing required tool(s): {', '.join(missing)} " + f"(this script targets Wayland/Hyprland)") + + +def hypr(*args): + """Run `hyprctl -j <args>` and return parsed JSON.""" + out = subprocess.run(["hyprctl", "-j", *args], capture_output=True, text=True) + if out.returncode != 0: + die(f"hyprctl {' '.join(args)} failed: {out.stderr.strip()}") + return json.loads(out.stdout) + + +def hypr_run(*args): + """Fire a hyprctl dispatch/keyword command (no JSON parse).""" + subprocess.run(["hyprctl", *args], capture_output=True, text=True) + + +def grim(output, geometry=None, output_name=None): + cmd = ["grim"] + if geometry: + cmd += ["-g", geometry] + if output_name: + cmd += ["-o", output_name] + cmd.append(output) + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + die(f"grim failed: {res.stderr.strip()}") + + +def list_windows(): + rows = sorted(hypr("clients"), + key=lambda c: (c.get("workspace", {}).get("id", 0), c.get("class", ""))) + if not rows: + print("(no open windows)") + return + for c in rows: + ws = c.get("workspace", {}).get("name", "?") + print(f" ws:{ws:<8} class={c.get('class','')!r:30} title={c.get('title','')!r}") + + +def find_window(regex): + matches = match_windows(hypr("clients"), regex) + if not matches: + die(f"no window matches {regex!r} — run --list to see open windows") + if len(matches) > 1: + labels = "\n".join(f" class={c.get('class','')!r} title={c.get('title','')!r}" + for c in matches) + die(f"{len(matches)} windows match {regex!r}; narrow it:\n{labels}") + return matches[0] + + +def capture_launched(cmd, out, settle, timeout, layout, size): + """Run CMD on a transient headless output, capture it, then tear down. + + Creates a virtual (headless) Hyprland output that the compositor renders but + does not display, launches CMD onto its workspace without switching the + visible view, applies LAYOUT, captures the output, and removes everything. + + LAYOUT: 'tiled'/'monocle' (window fills the output, sized by SIZE) or + 'floating' (window floats at SIZE, centered). SIZE is (w, h) or None. + """ + before_mons = {m["name"] for m in hypr("monitors")} + hypr_run("output", "create", "headless") + time.sleep(0.4) + new_mons = [m for m in hypr("monitors") if m["name"] not in before_mons] + if not new_mons: + die("could not create a headless output") + name = new_mons[0]["name"] + wsid = new_mons[0]["activeWorkspace"]["id"] + try: + if size and layout in ("tiled", "monocle"): + hypr_run("keyword", "monitor", f"{name},{size[0]}x{size[1]}@60,0x0,1") + time.sleep(0.2) + + before_wins = {c["address"] for c in hypr("clients")} + hypr_run("dispatch", "exec", f"[{launch_rule(wsid, layout)}] {cmd}") + + deadline = time.time() + timeout + new_wins = [] + while time.time() < deadline: + new_wins = [c for c in hypr("clients") if c["address"] not in before_wins] + if new_wins: + break + time.sleep(0.3) + if not new_wins: + die(f"no window appeared within {timeout}s of launching: {cmd}") + + # Daemon-spawned frames (emacsclient) ignore the exec rule, so apply + # placement and layout by address — robust for both spawn paths. + mon = next(m for m in hypr("monitors") if m["name"] == name) + ow, oh = mon["width"], mon["height"] + for c in new_wins: + addr = c["address"] + if c.get("workspace", {}).get("id") != wsid: + hypr_run("dispatch", "movetoworkspacesilent", f"{wsid},address:{addr}") + if layout == "floating": + hypr_run("dispatch", "setfloating", f"address:{addr}") + if size: + hypr_run("dispatch", "resizewindowpixel", + f"exact {size[0]} {size[1]},address:{addr}") + cx, cy = center_offset(ow, oh, size[0], size[1]) + hypr_run("dispatch", "movewindowpixel", f"exact {cx} {cy},address:{addr}") + + time.sleep(settle) + grim(out, output_name=name) + finally: + for c in hypr("clients"): + if c.get("workspace", {}).get("id") == wsid: + hypr_run("dispatch", "closewindow", f"address:{c['address']}") + hypr_run("output", "remove", name) + + +def resolve_output(path): + out = path or f"/tmp/claude-screenshot-{datetime.now():%Y%m%d-%H%M%S}.png" + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + return out + + +def main(): + p = argparse.ArgumentParser(add_help=True, description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + mode = p.add_mutually_exclusive_group() + mode.add_argument("--full", action="store_true", help="capture all outputs (default)") + mode.add_argument("--active", action="store_true", help="capture the focused window") + mode.add_argument("--window", metavar="REGEX", help="capture window matching REGEX") + mode.add_argument("--list", action="store_true", help="list windows and exit") + mode.add_argument("--launch", metavar="CMD", help="launch CMD off-screen and capture it") + p.add_argument("--layout", choices=("tiled", "monocle", "floating"), default="tiled", + help="window layout for --launch (default: tiled)") + p.add_argument("--size", help="WxH: output res (tiled/monocle) or window size (floating)") + p.add_argument("--timeout", type=float, default=10.0, + help="seconds to wait for the --launch window (default 10)") + p.add_argument("--output", "-o", help="PNG output path") + p.add_argument("--delay", type=float, default=0.0, help="seconds to wait before capture") + args = p.parse_args() + + require_tools("grim", "hyprctl") + + if args.list: + list_windows() + return + + size = parse_size(args.size) if args.size else None + + if args.launch: + out = resolve_output(args.output) + settle = args.delay if args.delay else 1.5 + capture_launched(args.launch, out, settle, args.timeout, args.layout, size) + print(out) + return + + if args.delay: + time.sleep(args.delay) + + geometry = None + if args.window: + geometry = geometry_str(find_window(args.window)) + elif args.active: + win = hypr("activewindow") + if not win or "at" not in win: + die("no active window") + geometry = geometry_str(win) + # else: --full / default → whole screen + + out = resolve_output(args.output) + grim(out, geometry) + print(out) + + +if __name__ == "__main__": + main() diff --git a/.ai/scripts/tests/test_screenshot.py b/.ai/scripts/tests/test_screenshot.py new file mode 100644 index 0000000..357caec --- /dev/null +++ b/.ai/scripts/tests/test_screenshot.py @@ -0,0 +1,97 @@ +"""Tests for screenshot.py — the pure helpers behind Wayland screenshot capture. + +The script is mostly thin wrappers around grim/hyprctl (I/O boundaries, verified +functionally). The logic worth testing is pure: size parsing, grim geometry +strings, window matching, the Hyprland exec-rule body, and floating-window +centering. Those are imported and exercised directly here; the subprocess +wrappers and the capture orchestration are not unit-tested. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import screenshot # noqa: E402 + + +# ------------------------------- parse_size ---------------------------------- + +def test_parse_size_normal(): + assert screenshot.parse_size("1600x1000") == (1600, 1000) + +def test_parse_size_boundary_zero_and_one(): + assert screenshot.parse_size("0x0") == (0, 0) + assert screenshot.parse_size("1x1") == (1, 1) + +@pytest.mark.parametrize("bad", ["", "bad", "100", "100x", "x100", "100X100", " 1x1"]) +def test_parse_size_error_malformed_dies(bad): + with pytest.raises(SystemExit): + screenshot.parse_size(bad) + + +# ------------------------------ geometry_str --------------------------------- + +def test_geometry_str_normal(): + assert screenshot.geometry_str({"at": [10, 20], "size": [800, 600]}) == "10,20 800x600" + +def test_geometry_str_boundary_origin_and_unit(): + assert screenshot.geometry_str({"at": [0, 0], "size": [1, 1]}) == "0,0 1x1" + + +# ------------------------------ match_windows -------------------------------- + +CLIENTS = [ + {"class": "Emacs", "title": "Emacs 30.2 : agent [.emacs.d]"}, + {"class": "firefox", "title": "TrueNAS — Mozilla Firefox"}, + {"class": "foot", "title": "foot"}, +] + +def test_match_windows_normal_by_class(): + assert screenshot.match_windows(CLIENTS, "firefox") == [CLIENTS[1]] + +def test_match_windows_normal_by_title(): + assert screenshot.match_windows(CLIENTS, "TrueNAS") == [CLIENTS[1]] + +def test_match_windows_case_insensitive(): + assert screenshot.match_windows(CLIENTS, "EMACS") == [CLIENTS[0]] + +def test_match_windows_multiple(): + # "o" appears in firefox and foot classes + assert len(screenshot.match_windows(CLIENTS, "foo")) == 1 + assert len(screenshot.match_windows(CLIENTS, "e")) >= 2 # Emacs + firefox (title/class) + +def test_match_windows_boundary_empty_clients(): + assert screenshot.match_windows([], "anything") == [] + +def test_match_windows_boundary_missing_fields(): + # client with no class/title keys must not raise + assert screenshot.match_windows([{"class": None, "title": None}], "x") == [] + +def test_match_windows_error_no_match(): + assert screenshot.match_windows(CLIENTS, "nonexistent-zzz") == [] + + +# ------------------------------- launch_rule --------------------------------- + +def test_launch_rule_tiled_default(): + assert screenshot.launch_rule(2, "tiled") == "workspace 2 silent" + +def test_launch_rule_monocle_adds_fullscreen(): + assert screenshot.launch_rule(2, "monocle") == "workspace 2 silent;fullscreen 1" + +def test_launch_rule_floating_adds_float(): + assert screenshot.launch_rule(7, "floating") == "workspace 7 silent;float" + + +# ------------------------------ center_offset -------------------------------- + +def test_center_offset_normal(): + assert screenshot.center_offset(1920, 1080, 800, 600) == (560, 240) + +def test_center_offset_boundary_exact_fit(): + assert screenshot.center_offset(1920, 1080, 1920, 1080) == (0, 0) + +def test_center_offset_error_window_larger_than_output_clamps(): + assert screenshot.center_offset(800, 600, 1000, 700) == (0, 0) diff --git a/.ai/workflows/task-audit.org b/.ai/workflows/task-audit.org index d9767e5..edbaa8d 100644 --- a/.ai/workflows/task-audit.org +++ b/.ai/workflows/task-audit.org @@ -73,6 +73,7 @@ For every STALE task, edit it in the main thread: - Mark statuses that moved; rewrite "waiting on X" lines whose X resolved. - Fix dead/renamed =file:= links. - *Consolidate duplicates* — when several tasks track the same thing, fold them into one home and delete the duplicates (per the user's call on which is canonical). +- *Re-assess the =:quick:= and =:solo:= tags* — reconciliation can change a task's effort or autonomy: a resolved dependency may make a stuck task =:solo:=, a scope cut may make it =:quick:=, and new complexity surfaced by the sources can invalidate either. Add or remove the tags per the definitions in [[file:task-review.org][task-review.org]] when the reconciled facts make the call clear. When they don't — an effort estimate you can't pin down, a =:solo:= gate you can't confirm — it's a NEEDS-USER flag, not a guess. - Bump =:LAST_REVIEWED:= on each edited task. Follow =todo-format.md= for completion mechanics (depth-based DONE vs dated-rewrite) and the working-files / link-hygiene rules when moving artifacts. diff --git a/.ai/workflows/task-review.org b/.ai/workflows/task-review.org index 6a9a266..7cc1e29 100644 --- a/.ai/workflows/task-review.org +++ b/.ai/workflows/task-review.org @@ -61,6 +61,12 @@ While reviewing each task, estimate its effort. If you judge it *30 minutes or l This is orthogonal to the action chosen — a task can be kept (or re-graded, or marked DOING) *and* tagged =:quick:= in the same pass. Skip the assessment on a Kill, since it's leaving the pool. Tags go on the heading line per [[file:../../claude-rules/todo-format.md][todo-format.md]], sharing one =:tag1:tag2:= cluster. +*** Tagging =:solo:= — tasks Claude can finish end-to-end + +While reviewing each task, judge whether Claude could finish it without Craig's input, and if so add =:solo:= to the heading line. Three gates, all of which must hold: the scope is well-defined and bounded, there's no design or preference call that needs Craig, and the outcome is verifiable locally — no waiting on hardware Craig owns, an external service, or Craig's own confirmation that the result is right. If any gate is shaky, leave the tag off. Like =:quick:=, a wrong =:solo:= is worse than none — it tells Craig he can hand the task off and walk away, so a mislabeled one wastes that trust. When the heading and body don't make all three gates clear, ask Craig instead of guessing. + +=:solo:= is independent of both the action and =:quick:=. A task can be =:solo:= but slow (a bounded refactor that takes hours yet needs no input) or =:quick:= but not =:solo:= (a five-minute change that hinges on a preference call). Tag each axis on its own merits; both share the one =:tag1:tag2:= cluster. Skip the assessment on a Kill. + *** Stamping =:LAST_REVIEWED:= Set =:LAST_REVIEWED:= to today's date (from above) in the task's =:PROPERTIES:= drawer: @@ -86,7 +92,7 @@ Follow the completion rules in [[file:../../claude-rules/todo-format.md][todo-fo When the batch is done (or Craig calls it early): -1. Summarize what changed — re-grades, kills, anything marked DOING, anything newly tagged =:quick:= — in a couple of lines. Don't list the keeps individually; "the rest were confirmed as-is" is enough. +1. Summarize what changed — re-grades, kills, anything marked DOING, anything newly tagged =:quick:= or =:solo:= — in a couple of lines. Don't list the keeps individually; "the rest were confirmed as-is" is enough. 2. The edits are already written to =todo.org=. If Craig keeps =todo.org= open in Emacs, remind him to revert the buffer so his editor picks up the changes (and flag that any unsaved edits he had open could collide — re-running picks up whatever's on disk). * Common Mistakes @@ -98,6 +104,7 @@ When the batch is done (or Craig calls it early): 5. *Drifting the date format* — =:LAST_REVIEWED:= must be =YYYY-MM-DD=, or the staleness script won't read it. 6. *Marking a kill DONE instead of CANCELLED* — DONE means finished, CANCELLED means abandoned. A task review kills tasks that shouldn't be done at all. 7. *Guessing a =:quick:= estimate* — if the heading and body don't make the effort clear, ask Craig instead of tagging on a hunch. A mislabeled =:quick:= defeats the tag's purpose. +8. *Over-tagging =:solo:=* — if you can't confirm all three gates (bounded scope, no preference call, locally verifiable), leave it off. A =:solo:= that actually needs Craig's input, his hardware, or his sign-off to verify defeats the tag's purpose. * Living Document diff --git a/.ai/workflows/triage-intake.org b/.ai/workflows/triage-intake.org index 36f9530..02e36e8 100644 --- a/.ai/workflows/triage-intake.org +++ b/.ai/workflows/triage-intake.org @@ -81,7 +81,10 @@ All three mail accounts are synced to local Maildirs and =mu=-indexed: =~/.mail/ 1. Query every unread INBOX message across the three accounts: #+begin_src bash mu find 'flag:unread AND NOT flag:trashed AND (maildir:/gmail/INBOX OR maildir:/dmail/INBOX OR maildir:/cmail/INBOX)' \ - --fields='p' + --fields='l' + # --fields='l' is the file location (full path). Don't use 'p' — in current + # mu that's the priority field and returns "normal" for every row, which + # makes the flag manager error on every path (caught 2026-05-22). #+end_src 2. Pass *all* the returned paths to the flag manager in one call: #+begin_src bash |
