From 6a65f2138647921988d40160048eb7d4200fdb38 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Mon, 28 Sep 2026 22:01:31 +0100 Subject: [PATCH] feat: Eyes board chip + strip, eyes conductor --instance via the PyAutoEyes registry (PyAutoEyes 2) - config/policy.yaml boards gains eyes: PyAutoEyes, between memory and heart in the canonical organ order. - board/_theme.py gets ORGANS["eyes"] (a designed iris gold, clear of the Hands orange; contrast is 5.7:1 light and 11.6:1 dark) and MARKS["eyes"] (an eye in line art). - board/_board.py gets collect_eyes(). It reads the counts table at the head of the PyAutoEyes dashboard.md (instances, figures, behind, critiques) and renders an Eyes line beside the Cortex strip. The Cortex read is factored into _dashboard_counts(). - The eyes conductor gets --instance (repeatable). It resolves through the PyAutoEyes registry.yaml, found at $PYAUTO_EYES, then the sibling checkout, then _repo_paths. Handing survey/review the organ root covers every registered instance, and --json emits an EyesInstanceSet that records the instances it skipped. A resolved survey also checks that the registry's tracked manifest is present. The registry is read stdlib-only, and the code still names no instance (pinned by a test). - Prose: agents/conductors/eyes/AGENTS.md and skills/eyes/eyes.md now document --instance and the dashboard critique route. PyAutoMind#451. Follows #428 (PyAutoMind#448). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CpGs8DxC5qA1MtHcmHeaBQ --- agents/conductors/eyes/AGENTS.md | 33 ++++- agents/conductors/eyes/_eyes.py | 238 ++++++++++++++++++++++++++++--- board/_board.py | 57 ++++++-- board/_theme.py | 28 ++++ config/policy.yaml | 1 + skills/eyes/eyes.md | 28 +++- tests/test_board.py | 47 ++++++ tests/test_board_theme.py | 6 +- tests/test_eyes_conductor.py | 107 +++++++++++++- 9 files changed, 505 insertions(+), 40 deletions(-) diff --git a/agents/conductors/eyes/AGENTS.md b/agents/conductors/eyes/AGENTS.md index 1dfa561..34b5b0d 100644 --- a/agents/conductors/eyes/AGENTS.md +++ b/agents/conductors/eyes/AGENTS.md @@ -34,9 +34,18 @@ and track one library's figures — producers, datasets, tracked PNGs, `GALLERY.md`, a tracked `gallery/viz_manifest.yaml` and the `gallery/gallery_run.sh` harness; **the organ PyAutoEyes** is the cross-project dashboard that reads each project repo's tracked manifest and -links to its PNGs — it renders nothing and copies no figures. The organ's -`registry.yaml` listing every instance arrives in PyAutoEyes phase 1b/2; -until then the instance is named here and in the `/eyes` skill. +links to its PNGs — it renders nothing and copies no figures. + +**Instances by name.** The organ's `registry.yaml` is the list of +instances, and it is data rather than code. `--instance ` (repeatable) +resolves an instance's checkout through it: first `path` (grouped layout), +then `repo` (flat task bundle), under the workspace root. The organ checkout +is found at `$PYAUTO_EYES`, then beside this Brain, then through +`_repo_paths` (PyAutoEyes). Hand `survey` or `review` the organ root itself to +cover every registered instance. An instance with no local checkout is skipped +with a note on stderr, never guessed at. A resolved instance's survey also +checks that the registry's tracked `manifest` path is present. The `.py` names +the organ and no instance, and a test pins that. ## Modes @@ -45,6 +54,7 @@ until then the instance is named here and in the `/eyes` skill. | `survey` | What figures exist, what is stale, what was never rendered, is the gallery current? | `EyesSurvey` — per-script inventory, stale/gap/orphan lists, gallery currency, next action | | `review` | What do I look at, in what order, and how do critiques become work? | `EyesReviewSurface` — ordered figure batches for the agentic read loop + the critique-note schema and edit-surface routing | | `review --against ` | Paper-informed pass: how should these figures change to match this paper's conventions? | the same surface + `reference_figures` (the paper's extracted panels); notes then carry a `reference` | +| either mode, several instances | The same question across the registry (`--instance a --instance b`, or the organ root) | `EyesInstanceSet` under `--json`: `decisions` (one per local instance, each carrying `instance`) and `skipped` (instance and reason); plain text prints each decision in turn | ## The loop (driven by the `/eyes` skill) @@ -61,6 +71,16 @@ until then the instance is named here and in the `/eyes` skill. 5. Delegate: one intake prompt per coherent accepted change, routed through start_dev. **Never edit plot source in-session.** +Critiques can also start on the PyAutoEyes dashboard. Each figure there has a +copyable `/eyes review
` line (paste it into a session and +this loop starts at step 3 for that figure) and a **Suggest an improvement** +link. That link opens a pre-filled `eyes-critique` issue on the project repo, +which a human files. Such an issue is raw input for step 5, the same as a +note: the dashboard files nothing, and nothing ships without intake → +start_dev. The dashboard lists open critiques (the PyAutoMind drafts that +mention an instance) and calls this conductor's `--json survey` for its +per-instance survey line. + ## Boundaries - Decision-only, stdlib-only core: reads the filesystem, writes nothing. @@ -80,8 +100,13 @@ until then the instance is named here and in the `/eyes` skill. ```bash bin/pyauto-brain eyes survey +bin/pyauto-brain eyes survey --instance # through PyAutoEyes registry.yaml +bin/pyauto-brain eyes survey # every registered instance bin/pyauto-brain eyes review [--batch N] [--against ] +bin/pyauto-brain eyes review --instance [--batch N] bin/pyauto-brain eyes --json survey ``` -Exit codes: 0 decision emitted · 4 not a visualization workspace. +Exit codes: 0 decision emitted · 2 unknown instance or unreadable registry · +4 not a visualization workspace (or no registered instance is checked out +here). diff --git a/agents/conductors/eyes/_eyes.py b/agents/conductors/eyes/_eyes.py index 19bdebd..f914022 100644 --- a/agents/conductors/eyes/_eyes.py +++ b/agents/conductors/eyes/_eyes.py @@ -18,6 +18,13 @@ figures (a paper's extracted panels) ride along as reference context; notes may then carry a `reference` +Instances by name: `--instance ` (repeatable) resolves a checkout +through the PyAutoEyes organ's `registry.yaml` — the registry is data, so +this file still names no instance. Handing either mode the organ root +itself (a directory holding `registry.yaml`) covers every registered +instance. A registered instance with no local checkout is skipped with a +note, never guessed at. + Decision-only, stdlib-only: reads the filesystem, writes nothing, renders nothing (rendering is the workspace's `gallery/gallery_run.sh`), and never edits plot source — accepted critiques route to intake/start_dev. @@ -25,9 +32,25 @@ import argparse import json +import os +import re import sys from pathlib import Path +BRAIN_HOME = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(BRAIN_HOME / "agents")) +import _pyauto_root # noqa: E402 +from _repo_paths import repo_path # noqa: E402 + +# The organ that holds the instance registry — an organ name, not an +# instance fact (the same footing as the Cortex conductor's CORTEX_REPO). +EYES_REPO = "PyAutoEyes" +REGISTRY_FILE = "registry.yaml" + +# Exit codes: 0 decision emitted · 2 unknown instance / unreadable registry +# · 4 not a visualization workspace (or no registered instance is local). +RC_OK, RC_REGISTRY, RC_NOT_WORKSPACE = 0, 2, 4 + # Any script whose stem contains this token is treated as a figure producer; # its images land in scripts//images//. PRODUCER_TOKEN = "visualization" @@ -80,23 +103,31 @@ def scan(root: Path) -> dict: return {"records": records} -def gallery_status(root: Path, records) -> dict: +def gallery_status(root: Path, records, tracked_manifest: str | None = None) -> dict: gallery = root / "output" / "gallery" html = gallery / "gallery.html" manifest = gallery / "viz_manifest.yaml" newest = max((r["newest_png_mtime"] for r in records if r["newest_png_mtime"] is not None), default=None) built = html.is_file() - return { + status = { "built": built, "manifest": manifest.is_file(), "stale": (built and newest is not None and newest > html.stat().st_mtime), "path": str(gallery.relative_to(root)), } + if tracked_manifest is not None: + # The registry row names the manifest the project repo commits (the + # organ's read contract); the local output/gallery build is optional. + status["tracked_manifest"] = { + "path": tracked_manifest, + "present": (root / tracked_manifest).is_file(), + } + return status -def survey(root: Path) -> dict: +def survey(root: Path, tracked_manifest: str | None = None) -> dict: records = scan(root)["records"] return { "kind": "EyesSurvey", @@ -109,12 +140,112 @@ def survey(root: Path) -> dict: if not r["script_exists"]], "stale_renders": [f"{r['domain']}/{r['script']}" for r in records if r["stale"]], - "gallery": gallery_status(root, records), + "gallery": gallery_status(root, records, tracked_manifest), "next_action": ("run the workspace's gallery/gallery_run.sh " "for stale/missing renders, then `eyes review`"), } +# -------------------------------------------------------------- registry --- +class RegistryError(Exception): + """The organ registry is missing, unreadable, or lacks a named instance.""" + + +def eyes_root(explicit: str | None = None) -> Path: + """Where the PyAutoEyes organ is: `explicit` → `$PYAUTO_EYES` → beside + this Brain checkout → `repo_path($PYAUTO_ROOT, PyAutoEyes)`.""" + if explicit: + return Path(explicit).expanduser().resolve() + env = os.environ.get("PYAUTO_EYES") + if env: + return Path(env).expanduser().resolve() + sibling = BRAIN_HOME.parent / EYES_REPO + if (sibling / REGISTRY_FILE).is_file(): + return sibling.resolve() + return repo_path(_pyauto_root.pyauto_root(), EYES_REPO).resolve() + + +_ROW = re.compile(r"^ - (\w+):\s*(.*?)\s*$") +_FIELD = re.compile(r"^ (\w+):\s*(.*?)\s*$") + + +def _scalar(raw: str) -> str: + raw = raw.split(" #", 1)[0].strip() + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"": + return raw[1:-1] + return raw + + +def read_registry(organ: Path) -> list[dict]: + """The `instances:` rows of the organ's registry.yaml, stdlib-only. + + The registry is a flat list of string mappings (validated by the organ's + own `pyauto-eyes check`); this reads exactly that shape and nothing + more, so the conductor needs no YAML dependency. + """ + path = organ / REGISTRY_FILE + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise RegistryError(f"cannot read the instance registry {path}: {exc}") from exc + rows, active = [], False + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if line.rstrip() == "instances:": + active = True + continue + if not active: + continue + if not line.startswith(" "): + break + m = _ROW.match(line) + if m: + rows.append({m[1]: _scalar(m[2])}) + continue + m = _FIELD.match(line) + if m and rows: + rows[-1][m[1]] = _scalar(m[2]) + rows = [r for r in rows if r.get("name")] + if not rows: + raise RegistryError(f"no instances in {path}") + return rows + + +def is_organ_root(path: Path) -> bool: + """A directory holding the instance registry and no figure tree of its own.""" + return (path / REGISTRY_FILE).is_file() and not (path / "scripts").is_dir() + + +def instance_checkout(row: dict, organ: Path) -> Path | None: + """A registered instance's local checkout: grouped `/`, then + flat `/`, for the workspace root and the roots the organ + itself sits under. None when it is not on this machine.""" + roots = [_pyauto_root.pyauto_root(), organ.parent.parent, organ.parent] + seen = [] + for base in roots: + if base in seen: + continue + seen.append(base) + for rel in (row.get("path"), row.get("repo")): + if rel and (base / rel / "scripts").is_dir(): + return (base / rel).resolve() + return None + + +def select_instances(organ: Path, names: list[str] | None) -> list[dict]: + rows = read_registry(organ) + if not names: + return rows + known = {r["name"]: r for r in rows} + missing = [n for n in names if n not in known] + if missing: + raise RegistryError( + f"no instance {', '.join(map(repr, missing))} in {organ / REGISTRY_FILE} " + f"(known: {', '.join(known)})") + return [known[n] for n in names] + + REFERENCE_SUFFIXES = (".png", ".jpg", ".jpeg") @@ -158,6 +289,8 @@ def review(root: Path, batch: int, against: Path | None = None) -> dict: def _print_survey(s: dict): print("== EyesSurvey ==") + if s.get("instance"): + print(f"Instance: {s['instance']}") print(f"Workspace: {s['workspace']}") print(f"Domains: {', '.join(s['domains']) or '(none)'}") for r in s["records"]: @@ -172,11 +305,17 @@ def _print_survey(s: dict): state = "not built" if not g["built"] else ("STALE" if g["stale"] else "current") print(f"Gallery: {g['path']} — {state}" f"{' (manifest missing)' if g['built'] and not g['manifest'] else ''}") + tracked = g.get("tracked_manifest") + if tracked: + print(f"Tracked manifest: {tracked['path']} — " + f"{'present' if tracked['present'] else 'MISSING'}") print(f"Next action: {s['next_action']}") def _print_review(r: dict): print("== EyesReviewSurface ==") + if r.get("instance"): + print(f"Instance: {r['instance']}") print(f"Workspace: {r['workspace']}") print(f"Figures: {r['n_figures']} in {len(r['batches'])} " f"batch(es) of <= {r['batch_size']}") @@ -191,24 +330,60 @@ def _print_review(r: dict): print(f"Next action: {r['next_action']}") +def _targets(args): + """[(instance name or None, checkout, tracked manifest or None)] plus the + skipped instances [(name, reason)]. Raises RegistryError.""" + names = args.instance or [] + if args.workspace is None and not names: + raise SystemExit("eyes: give a workspace root, the PyAutoEyes organ " + "root, or --instance ") + if args.workspace is not None and not names: + root = Path(args.workspace).resolve() + if not is_organ_root(root): + return [(None, root, None)], [] + organ = root + elif args.workspace is not None: + organ = Path(args.workspace).resolve() + if not is_organ_root(organ): + raise SystemExit("eyes: --instance with a positional root needs " + "the PyAutoEyes organ root there") + else: + organ = eyes_root() + targets, skipped = [], [] + for row in select_instances(organ, names): + checkout = instance_checkout(row, organ) + if checkout is None: + skipped.append((row["name"], f"no local checkout at {row.get('path') or row.get('repo')}")) + else: + targets.append((row["name"], checkout, row.get("manifest"))) + return targets, skipped + + def main(argv=None) -> int: parser = argparse.ArgumentParser(prog="eyes") parser.add_argument("--json", action="store_true") sub = parser.add_subparsers(dest="mode", required=True) for mode in ("survey", "review"): p = sub.add_parser(mode) - p.add_argument("workspace", help="visualization-workspace root") + p.add_argument("workspace", nargs="?", default=None, + help="visualization-workspace root, or the PyAutoEyes " + "organ root (every registered instance)") + p.add_argument("--instance", action="append", metavar="NAME", + help="a registered instance, resolved through the " + "PyAutoEyes registry.yaml (repeatable)") if mode == "review": p.add_argument("--batch", type=int, default=8) p.add_argument("--against", default=None, help="reference-figure directory (paper-informed pass)") args = parser.parse_args(argv) - root = Path(args.workspace).resolve() - if not (root / "scripts").is_dir(): - print(f"eyes: not a visualization workspace (no scripts/): {root}", - file=sys.stderr) - return 4 + try: + targets, skipped = _targets(args) + except RegistryError as exc: + print(f"eyes: {exc}", file=sys.stderr) + return RC_REGISTRY + for name, reason in skipped: + print(f"eyes: instance {name}: {reason} — skipped", file=sys.stderr) against = None if args.mode == "review" and args.against is not None: @@ -218,17 +393,44 @@ def main(argv=None) -> int: for f in against.rglob("*"))): print(f"eyes: no reference figures (png/jpg) under: {against}", file=sys.stderr) - return 4 + return RC_NOT_WORKSPACE + + decisions = [] + for name, root, tracked in targets: + if not (root / "scripts").is_dir(): + print(f"eyes: not a visualization workspace (no scripts/): {root}", + file=sys.stderr) + if name is None: + return RC_NOT_WORKSPACE + skipped.append((name, f"not a visualization workspace: {root}")) + continue + decision = (survey(root, tracked) if args.mode == "survey" + else review(root, args.batch, against)) + if name is not None: + decision = {"instance": name, **decision} + decisions.append(decision) + if not decisions: + print("eyes: no registered instance has a local checkout here", + file=sys.stderr) + return RC_NOT_WORKSPACE - decision = (survey(root) if args.mode == "survey" - else review(root, args.batch, against)) + registry_run = targets and targets[0][0] is not None if args.json: - print(json.dumps(decision, indent=2)) - elif args.mode == "survey": - _print_survey(decision) + if registry_run and (len(decisions) > 1 or skipped): + print(json.dumps({ + "kind": "EyesInstanceSet", + "mode": args.mode, + "decisions": decisions, + "skipped": [{"instance": n, "reason": r} for n, r in skipped], + }, indent=2)) + else: + print(json.dumps(decisions[0], indent=2)) else: - _print_review(decision) - return 0 + for i, decision in enumerate(decisions): + if i: + print() + (_print_survey if args.mode == "survey" else _print_review)(decision) + return RC_OK if __name__ == "__main__": diff --git a/board/_board.py b/board/_board.py index ea6c455..352a53e 100755 --- a/board/_board.py +++ b/board/_board.py @@ -679,6 +679,25 @@ def collect_resume(org, degraded): "pending_prs": pending} +_COUNT_ROW = re.compile(r"^\|\s*\[([^\]]+)\]\([^)]*\)[^|]*\|\s*(\d+)\s*\|", re.M) + + +def _dashboard_counts(repo, head_only=False): + """`{label: n}` from an organ's generated `dashboard.md` counts table + (`| [Label](#anchor) | n |` rows), or None when the organ is not checked + out or its board has never been rendered. `head_only` reads only the part + above the first `## ` section, for a board whose body carries tables of + its own.""" + dash = repo_path(PYAUTO_ROOT, repo) / "dashboard.md" + if not dash.is_file(): + return None + text = dash.read_text(encoding="utf-8") + if head_only: + text = re.split(r"^## ", text, maxsplit=1, flags=re.M)[0] + counts = {label: int(n) for label, n in _COUNT_ROW.findall(text)} + return counts or None + + def collect_cortex(): """The Cortex's own generated counts (its `dashboard.md` counts table — compose, don't recompute), or None. @@ -690,16 +709,20 @@ def collect_cortex(): None and the strip simply does not appear — a science organ nobody has cloned is not a degraded morning. """ - dash = repo_path(PYAUTO_ROOT, "PyAutoCortex") / "dashboard.md" - if not dash.is_file(): - return None - counts = {} - for label, n in re.findall( - r"^\|\s*\[([^\]]+)\]\([^)]*\)[^|]*\|\s*(\d+)\s*\|", - dash.read_text(encoding="utf-8"), re.M, - ): - counts[label] = int(n) - return counts or None + return _dashboard_counts("PyAutoCortex") + + +def collect_eyes(repo="PyAutoEyes"): + """The Eyes' own generated counts (instances, figures, behind, critiques — + the counts table at the head of its `dashboard.md`), or None. + + Composed exactly as the Cortex strip is: the organ's renderer decides the + numbers, the Brain board only shows them. Only the head of the page is + read, because the body carries one table per figure domain. No Eyes + checkout, or no rendered board, is not a degraded morning either — the + strip just does not appear. + """ + return _dashboard_counts(repo, head_only=True) def collect_open_issues(org, degraded): @@ -903,6 +926,7 @@ def collect(): community = collect_community(degraded) resume = collect_resume(org, degraded) cortex = collect_cortex() + eyes = collect_eyes(board_family.get("eyes", "PyAutoEyes")) open_issues = collect_open_issues(org, degraded) boards = {name: f"{pages_base}/{repo}/" for name, repo in board_family.items()} @@ -921,6 +945,7 @@ def collect(): "community": community, "resume": resume, "cortex": cortex, + "eyes": eyes, "open_issues": open_issues, "hygiene": collect_hygiene(degraded), "devbox": collect_devbox(), @@ -1258,6 +1283,11 @@ def render_md(data): + " · ".join(f"{k.lower()} {n}" for k, n in data["cortex"].items()) + f" — [Cortex board]({data['boards'].get('cortex', '')})") + if data.get("eyes"): + L.append("- Eyes: " + + " · ".join(f"{k.lower()} {n}" + for k, n in data["eyes"].items()) + + f" — [Eyes board]({data['boards'].get('eyes', '')})") for t in data["resume"]["tasks"]: L.append(f" - `/start_dev {t['path']}` — {t['title'][:70]}") pending = data["resume"]["pending_prs"] @@ -1625,6 +1655,13 @@ def community_row(e, note, tone): "board \u2197" + pills(*[(f"{k.lower()} {n}", "" if i == 0 else "n") for i, (k, n) in enumerate(data["cortex"].items())]))) + if data.get("eyes"): + eyes_url = data["boards"].get("eyes", "") + H.append(_plain( + f'Figures on the Eyes ' + "board \u2197" + + pills(*[(f"{k.lower()} {n}", "" if i == 0 else "n") + for i, (k, n) in enumerate(data["eyes"].items())]))) for t in data["resume"]["tasks"]: # An in-flight task wears the header facets the Mind gave it, so it # looks like itself on both pages — same pills, same order. diff --git a/board/_theme.py b/board/_theme.py index c11995d..0654262 100644 --- a/board/_theme.py +++ b/board/_theme.py @@ -93,6 +93,17 @@ "glow2": "#8b5cf6", "hero": ("#221345", "#000000"), }, + # Eyes — the organ has no logo file yet, so, like the umbrella, its + # accent is designed to sit in the family: an iris gold, kept well clear + # of the Hands orange beside it in the footer. #7d6300 reads at 5.7:1 on + # #fff and #f5c518 at 11.6:1 on #0d1117. The tagline is the one the + # organ's own dashboard already wears. + "eyes": { + "organ": "Eyes", + "tagline": "See. Compare. Improve.", + "ink_light": "#7d6300", "ink_dark": "#f5c518", "glow": "#ffc933", + "hero": ("#2e2503", "#000000"), + }, "heart": { "organ": "Heart", "tagline": "Check. Validate. Protect.", @@ -266,6 +277,23 @@ '' ), + # Eyes — an open eye: the almond lid, the iris ring and its pupil, and + # three node-tipped lashes, inside a closed ring (no logo file yet). + "eyes": ( + '' + '' + '' + '' + '' + '' + '' + '' + '' + ), # Heart — a heart outline crossed by an ECG trace, with the check badge # sitting in the ring's lower-right gap. "heart": ( diff --git a/config/policy.yaml b/config/policy.yaml index 7e6aee3..a2f05a1 100644 --- a/config/policy.yaml +++ b/config/policy.yaml @@ -170,6 +170,7 @@ board: mind: PyAutoMind cortex: PyAutoCortex memory: PyAutoMemory + eyes: PyAutoEyes heart: PyAutoHeart hands: PyAutoHands nerves: PyAutoNerves diff --git a/skills/eyes/eyes.md b/skills/eyes/eyes.md index 69d21bb..b4bf2ca 100644 --- a/skills/eyes/eyes.md +++ b/skills/eyes/eyes.md @@ -17,12 +17,14 @@ and track one library's figures — producers, datasets, tracked PNGs, `gallery/gallery_run.sh` harness; **the organ PyAutoEyes** is the cross-project dashboard that reads each project repo's tracked manifest and links to its PNGs — it renders nothing and copies no figures. The organ's -`registry.yaml` listing every instance arrives in PyAutoEyes phase 1b/2; -until then the instance is named here and in the conductor's AGENTS.md. +`registry.yaml` lists every instance. Name one with `--instance ` (the +lens instance is `lens`), or hand the conductor the PyAutoEyes root to cover +them all. ## Do -1. **Survey** — `bin/pyauto-brain eyes survey `: per-script +1. **Survey**: `bin/pyauto-brain eyes survey --instance ` (or + ``, or the PyAutoEyes root for every instance): per-script figure inventory, stale renders (producer script newer than its figures), never-rendered gaps, gallery currency. 2. **Render** what the survey flags, in the instance itself: @@ -30,7 +32,8 @@ until then the instance is named here and in the conductor's AGENTS.md. builder's own `--check`; `--all` adds the slow tier + JAX variants). Then `python gallery/gallery_build.py --embed` and copy `output/gallery/gallery_embedded.html` out (e.g. `towin`) for the human. -3. **Review** — `bin/pyauto-brain eyes review `: read each +3. **Review**: `bin/pyauto-brain eyes review --instance ` (or + ``): read each figure batch directly (PNG reads in-session), collect the human's critiques plus your own suggestions as notes against the emitted `note_schema`, tagging each with its edit surface (`config` / @@ -40,6 +43,23 @@ until then the instance is named here and in the conductor's AGENTS.md. `/start_dev` as usual (config + script surfaces → workspace PR; `plot_api` → library PR). **Never edit plot source inside the review session.** +## From the dashboard + +The PyAutoEyes dashboard () gives +every figure two critique routes, and neither files anything by itself: + +- **`/eyes review
`**: the human pastes it here. Resolve + the instance with `--instance `, read that one figure (and its + siblings from the same producer, for context), and continue at step 3 with + a single-figure batch. +- **Suggest an improvement**: a pre-filled `eyes-critique` issue on the + project repo, which the human files. Treat an open one as a critique note + whose `accepted` field is still false. Discuss it, and on explicit + agreement file the `/intake` prompt (step 4) and link the issue from it. + +The dashboard also lists the open PyAutoMind drafts that mention each instance +(its open critiques), so check there before filing a duplicate. + ## Paper-informed pass ("restyle to match this paper") When the human supplies a paper (PDF, arXiv link, or a directory of figure diff --git a/tests/test_board.py b/tests/test_board.py index b248c79..50dd9b8 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -34,6 +34,9 @@ # The science organ's own counts (its dashboard.md counts table), None # when no Cortex is checked out. "cortex", + # The Eyes organ's own counts (the head of its dashboard.md), None when + # no Eyes is checked out. + "eyes", } AUTONOMY_LOG = """\ @@ -184,6 +187,27 @@ def _default_fixtures(**overrides): | [Projects](#projects) | 3 | """ +# The head of the Eyes' generated board is its counts table; its body carries +# one table per figure domain, which the strip must not count. +EYES_DASHBOARD_MD = """\ +# PyAutoEyes — visualization dashboard + +| Where | Count | +|-------|------:| +| [Instances](#instances) | 2 | +| [Figures](#instances) | 40 | +| [Behind](#instances) | 1 | +| [Critiques](#instances) | 3 | + +## Instances + +| [alpha](#alpha) | 7 | 9 | + +## alpha + +| [fit.png](https://example.invalid/fit.png) | 5 | +""" + def _fabricate(tmp_path, fixtures, heart_board=None): """A PYAUTO_ROOT with a fabricated Mind, file:// sibling-board badges, and @@ -216,6 +240,9 @@ def _fabricate(tmp_path, fixtures, heart_board=None): cortex = tmp_path / "PyAutoCortex" cortex.mkdir() (cortex / "dashboard.md").write_text(CORTEX_DASHBOARD_MD) + eyes = tmp_path / "PyAutoEyes" + eyes.mkdir() + (eyes / "dashboard.md").write_text(EYES_DASHBOARD_MD) # One badge per sibling board named in the declared config surface — # read from policy.yaml so no board repo name is hardcoded here. @@ -310,6 +337,26 @@ def test_the_cortex_strip_composes_the_cortex_own_counts(tmp_path): assert json.loads(r.stdout)["cortex"] is None +def test_the_eyes_strip_composes_the_eyes_own_head_counts(tmp_path): + """The Eyes decides its own numbers; the Brain board reads only the + counts table at the head of its page (the body's per-domain tables are + not counts), links its board, and shows nothing when no Eyes is here.""" + s, _ = _surface(tmp_path) + assert s["eyes"] == {"Instances": 2, "Figures": 40, "Behind": 1, + "Critiques": 3} + assert s["boards"]["eyes"].endswith("/PyAutoEyes/") + stub = _fabricate(tmp_path / "md", _default_fixtures()) + md = _run([], tmp_path / "md", stub).stdout + assert "- Eyes: instances 2 · figures 40 · behind 1 · critiques 3" in md + page = _run(["--html"], tmp_path / "md", stub) + assert page.returncode == 0, page.stderr + assert "Eyes board" in page.stdout and 'data-organ="eyes"' in page.stdout + stub = _fabricate(tmp_path / "no_eyes", _default_fixtures()) + (tmp_path / "no_eyes" / "PyAutoEyes" / "dashboard.md").unlink() + r = _run(["--json"], tmp_path / "no_eyes", stub) + assert json.loads(r.stdout)["eyes"] is None + + def test_json_surface_is_complete_and_derives_org(tmp_path): s, _ = _surface(tmp_path) assert set(s) == SURFACE_KEYS diff --git a/tests/test_board_theme.py b/tests/test_board_theme.py index 7b233a5..995fd94 100644 --- a/tests/test_board_theme.py +++ b/tests/test_board_theme.py @@ -202,7 +202,7 @@ def test_board_links_reads_the_declared_family_in_file_order(): # ad-hoc order). This helper is the one read, so the order it returns is # the order `config/policy.yaml` declares — the ruled organ order. links = _theme.board_links("https://example.invalid") - assert list(links) == ["brain", "mind", "cortex", "memory", + assert list(links) == ["brain", "mind", "cortex", "memory", "eyes", "heart", "hands", "nerves", "gut", "organism"] assert list(links) == POLICY_BOARDS assert links["cortex"] == "https://example.invalid/PyAutoCortex/" @@ -225,8 +225,8 @@ def test_board_links_renders_the_family_footer_end_to_end(): footer = _theme.boards_footer( _theme.board_links("https://example.invalid", "heart"), "heart") order = re.findall(r'data-organ="(\w+)"', footer) - assert order == ["brain", "mind", "cortex", "memory", "hands", "nerves", - "gut", "organism"] + assert order == ["brain", "mind", "cortex", "memory", "eyes", "hands", + "nerves", "gut", "organism"] def test_board_links_returns_nothing_when_the_config_is_unreadable(): diff --git a/tests/test_eyes_conductor.py b/tests/test_eyes_conductor.py index 3244a27..e9d20fb 100644 --- a/tests/test_eyes_conductor.py +++ b/tests/test_eyes_conductor.py @@ -9,6 +9,7 @@ import json import os +import re import subprocess import time from pathlib import Path @@ -17,9 +18,10 @@ BRAIN = BRAIN_HOME / "bin" / "pyauto-brain" -def _run(args): +def _run(args, env=None): return subprocess.run( [str(BRAIN), "eyes", *args], capture_output=True, text=True, + env={**os.environ, **(env or {})}, ) @@ -137,3 +139,106 @@ def test_never_writes(tmp_path): for mode in (["survey", str(root)], ["review", str(root)]): assert _run(mode).returncode == 0 assert sorted(str(p) for p in root.rglob("*")) == before + + +# ---------------------------------------------------------- the registry --- +# `--instance ` resolves through the organ's registry.yaml. The fixture +# is an invented organ + registry (tenant firewall): one instance checked out +# in the grouped layout, one checked out flat, one not on this machine. +REGISTRY = """\ +# fabricated instance registry +schema: 1 +instances: + - name: alpha + repo: alpha_visualization + path: fam/alpha_visualization + github: ExampleOrg/alpha_visualization + manifest: gallery/viz_manifest.yaml + - name: beta + repo: beta_visualization + path: fam/beta_visualization # flat checkout only + manifest: "gallery/viz_manifest.yaml" + - name: gamma + repo: gamma_visualization + path: fam/gamma_visualization + manifest: gallery/viz_manifest.yaml +""" + + +def _organ_workspace(tmp_path): + ws = tmp_path / "ws" + organ = ws / "organs" / "EyesOrgan" + organ.mkdir(parents=True) + (organ / "registry.yaml").write_text(REGISTRY) + alpha = _make_workspace(ws / "fam" / "alpha_visualization") + (alpha / "gallery").mkdir() + (alpha / "gallery" / "viz_manifest.yaml").write_text("schema: 1\n") + _make_workspace(ws / "beta_visualization") + env = {"PYAUTO_ROOT": str(ws), "PYAUTO_EYES": str(organ)} + return ws, organ, env + + +def test_instance_resolves_through_the_registry(tmp_path): + ws, _, env = _organ_workspace(tmp_path) + r = _run(["--json", "survey", "--instance", "alpha"], env) + assert r.returncode == 0, r.stderr + s = json.loads(r.stdout) + assert s["kind"] == "EyesSurvey" and s["instance"] == "alpha" + assert Path(s["workspace"]) == (ws / "fam" / "alpha_visualization").resolve() + assert s["gaps"] == ["beta/visualization_jax"] + # The registry's tracked manifest is checked, beside the local gallery. + assert s["gallery"]["tracked_manifest"] == { + "path": "gallery/viz_manifest.yaml", "present": True} + # A flat bundle checkout (/) resolves too. + r = _run(["--json", "survey", "--instance", "beta"], env) + assert r.returncode == 0, r.stderr + beta = json.loads(r.stdout) + assert Path(beta["workspace"]) == (ws / "beta_visualization").resolve() + assert beta["gallery"]["tracked_manifest"]["present"] is False + + +def test_the_organ_root_means_every_registered_instance(tmp_path): + _, organ, env = _organ_workspace(tmp_path) + for mode in ("survey", "review"): + r = _run(["--json", mode, str(organ)], env) + assert r.returncode == 0, r.stderr + out = json.loads(r.stdout) + assert out["kind"] == "EyesInstanceSet" and out["mode"] == mode + assert [d["instance"] for d in out["decisions"]] == ["alpha", "beta"] + # The instance with no local checkout is skipped with a note, not guessed. + assert out["skipped"] == [{ + "instance": "gamma", + "reason": "no local checkout at fam/gamma_visualization"}] + assert "instance gamma: no local checkout" in r.stderr + text = _run(["survey", str(organ)], env) + assert text.stdout.count("== EyesSurvey ==") == 2 + assert "Instance: alpha" in text.stdout + + +def test_unknown_instance_names_the_known_ones(tmp_path): + _, _, env = _organ_workspace(tmp_path) + r = _run(["survey", "--instance", "delta"], env) + assert r.returncode == 2 + assert "no instance 'delta'" in r.stderr and "known: alpha, beta, gamma" in r.stderr + + +def test_an_instance_with_no_checkout_exits_4(tmp_path): + _, _, env = _organ_workspace(tmp_path) + r = _run(["survey", "--instance", "gamma"], env) + assert r.returncode == 4 + assert "no registered instance has a local checkout" in r.stderr + + +def test_a_missing_registry_exits_2(tmp_path): + env = {"PYAUTO_ROOT": str(tmp_path), "PYAUTO_EYES": str(tmp_path / "none")} + r = _run(["survey", "--instance", "alpha"], env) + assert r.returncode == 2 + assert "cannot read the instance registry" in r.stderr + + +def test_the_conductor_names_no_instance(): + # Tenant firewall: the registry is data. The conductor's code may name + # the organ that holds it, never a project repo or an instance key. + src = (BRAIN_HOME / "agents" / "conductors" / "eyes" / "_eyes.py").read_text() + assert "_visualization" not in src + assert not re.search(r"[\"']lens[\"']", src)