From 0aefbedb408d3486c235daf6c0e82fbf7b16125e Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 16 Jul 2026 09:45:47 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20community=20conductor=20(the=20Ears)=20?= =?UTF-8?q?=E2=80=94=20scan/triage=20user-filed=20issues=20(#119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Birth of the community conductor โ€” the organism's receptive language function: it hears the community (user-filed GitHub issues across every repos.yaml repo) and drafts what the organism says back; the human remains the mouth. Wernicke to the Workspace Agent's Broca/Voice (#118). - agents/conductors/community/{AGENTS.md,community.sh,_community.py}: two read-only, stdlib-only modes โ€” scan (open external issues over the repos.yaml homes, bot-filtered, awaiting-response detection ranked by waiting time) and triage (context-sufficiency signals + clarifying-question seeds + comment tail). Never posts, labels or writes; COMMUNITY_GH / COMMUNITY_SELF overrides keep tests hermetic. - bin/pyauto-brain: dispatch map, description, CONDUCTOR_ORDER; AGENTS.md command surface regenerated via install.sh --write-agents-surface. - skills/community/{SKILL.md,community.md}: the /community door โ€” the session drafts every outward reply for human approval; actionable work routes via /start_dev_for_user (which owns the comment templates). - skills/wake_up/wake_up.md: the community sensory leg โ€” scan step in the gh-API tier + ๐Ÿ’ฌ Community digest category (never auto-reply). - skills/COMMANDS.md + AGENTS.md: tier-1 registration. - tests/test_community_conductor.py: 6 hermetic contract tests (stub gh + fabricated repos.yaml; proves no mutating endpoint is hit, no file writes). Tests: 75 passed. Live-verified: scan found 5 external issues awaiting response (54d waiting); triage on PyAutoLens#531 detected 4/5 signals. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 12 + agents/conductors/community/AGENTS.md | 95 ++++++ agents/conductors/community/_community.py | 337 ++++++++++++++++++++++ agents/conductors/community/community.sh | 27 ++ bin/pyauto-brain | 5 +- skills/COMMANDS.md | 8 +- skills/community/SKILL.md | 13 + skills/community/community.md | 58 ++++ skills/wake_up/wake_up.md | 11 +- tests/test_community_conductor.py | 187 ++++++++++++ 10 files changed, 747 insertions(+), 6 deletions(-) create mode 100644 agents/conductors/community/AGENTS.md create mode 100644 agents/conductors/community/_community.py create mode 100644 agents/conductors/community/community.sh create mode 100644 skills/community/SKILL.md create mode 100644 skills/community/community.md create mode 100644 tests/test_community_conductor.py diff --git a/AGENTS.md b/AGENTS.md index 3fa3628..6d1f190 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,16 @@ humans invoke identically, so behaviour isn't re-derived from prose each time. the target, consults the sizing faculty and persists Difficulty/Autonomy/ Priority into the header, and emits an `IntakeDecision`. Files a prompt only; never starts dev (the step *before* `create_issue`/`start_dev`). +- **`agents/conductors/community/`** โ€” the *Ears*: the organism's receptive + language function โ€” it hears the community (user-filed GitHub issues across + every `repos.yaml` repo) and drafts what the organism says back; the human + remains the mouth. Wernicke to the Workspace Agent's Broca/Voice. Two + read-only modes: `scan` (open external issues + awaiting-response ranking โ€” + also the `/wake_up` community sensory leg) and `triage ` + (context-sufficiency signals + clarifying-question seeds). The `/community` + session judges, drafts every outward reply for human approval, and routes + actionable work via `start_dev_for_user`; conversation state stays on + GitHub + `active.md`. Never posts, labels or writes anything. - **`agents/conductors/feature/`** โ€” the *Growth Agent*: selects the next PyAutoMind `feature/*` task (or plans a named one), estimates difficulty, decides phasing, consults PyAutoMemory (and vitals for risky work), and emits @@ -232,6 +242,7 @@ the `/` slash commands. | Verb | Purpose | Entrypoint | |------|---------|------------| | `intake` | Conceive a task: turn raw input into a formal, headed PyAutoMind prompt (files it; never starts dev) | `bin/pyauto-brain intake` | +| `community` | The ears โ€” the organism's receptive function: scan/triage user-filed GitHub issues across the repos; drafts stay human-gated, dev work routes via start_dev_for_user (never posts) | `bin/pyauto-brain community` | | `feature` | Reason over PyAutoMind feature tasks: select, size, phase, plan for start_dev | `bin/pyauto-brain feature` | | `bug` | The immune system: classify a bug/regression/Heart finding, locate the fix, plan the repair | `bin/pyauto-brain bug` | | `refactor` | The renewal function: plan behaviour-preserving restructuring โ€” RefactorDecision; default-safe under --auto | `bin/pyauto-brain refactor` | @@ -269,6 +280,7 @@ the Brain routes it to the right agent; normal usage never says "PyAutoBrain". | Command | Routes to | Tier | |---------|-----------|------| | `/intake` | Intake Agent โ†’ files a PyAutoMind prompt (before `start_dev`) | real conductor | +| `/community` | Community Agent โ†’ scan/triage user-filed issues โ†’ human-approved replies โ†’ `start_dev_for_user` | real conductor | | `/feature` | Feature Agent โ†’ `start_dev` | real conductor | | `/bug` | Bug Agent โ†’ `start_dev` (health mode โ†’ vitals + Heart issues) | real conductor | | `/build` | Build Agent โ†’ vitals โ†’ Heart โ†’ PyAutoBuild | real conductor | diff --git a/agents/conductors/community/AGENTS.md b/agents/conductors/community/AGENTS.md new file mode 100644 index 0000000..4b113e5 --- /dev/null +++ b/agents/conductors/community/AGENTS.md @@ -0,0 +1,95 @@ +# Community agent + +> **Tier: conductor** โ€” a front-door agent you *drive*. The *Ears* โ€” the +> organism's receptive language function: it hears the community (user-filed +> GitHub issues across every repo) and drafts what the organism says back; the +> human remains the mouth. Wernicke to the Workspace Agent's Broca: that +> *Voice* speaks to users through examples and tutorials, this agent +> *comprehends and converses* โ€” it reads an outsider's issue, judges whether +> the context is sufficient, drafts every reply for human approval, and routes +> actionable work into the dev flow. It never posts, labels, edits or releases +> anything itself. + +Grown from demonstrated need: user-filed issues were handled ad-hoc (paste the +GitHub link into an agent chat), the `start_dev_for_user` skill already owned +the downstream dev entry, and the founding prompt asked for both a dedicated +listening/communicating agent and a `/wake_up` summary of what the community +is waiting on. Founding prompt: +PyAutoMind `active/community_communication_agent_listen_and_respond.md` +(issue PyAutoBrain#119). + +## Modes + +Both modes are live, deterministic and **read-only** โ€” they emit surfaces the +`/community` skill session reasons over. The judgment (actionable vs +ask-for-more, the reply prose, the routing call) is the session's; the human +gates every outward message. + +| Mode | Surface | Consumed by | +|------|---------|-------------| +| `scan` *(default)* | every `PyAutoMind/repos.yaml` repo โ†’ open issues authored by non-self humans (bots filtered), with **awaiting-response** detection (the conversation's last word is not ours) ranked by waiting time | `/community` step 1; the `/wake_up` community sensory leg | +| `triage ` | one issue โ†’ context-sufficiency signals (code block, traceback, versions, expected-vs-actual, data pointer), missing-signal clarifying-question seeds, comment tail, route | `/community` steps 2โ€“3 | + +``` +pyauto-brain community # scan: who is waiting on us? +pyauto-brain community scan --json +pyauto-brain community triage [--json] +``` + +Repo enumeration comes from `PyAutoMind/repos.yaml` (the body map) under +`PYAUTO_ROOT`; the org is searched wholesale, non-org homes individually. +GitHub access is the `gh` CLI โ€” `COMMUNITY_GH` overrides the binary (hermetic +tests), `COMMUNITY_SELF` the self logins (default `Jammy2211`). A failed +search degrades honestly (`degraded:` in the surface), never silently. + +## Fundamental principles + +- **The conductor hears; the human speaks.** Every outward message โ€” receipt, + clarifying question, plan update, closing note โ€” is drafted in the session + and presented to the human before posting. The CLI itself never mutates + GitHub. Autonomy for community work is `human-required` by design; `--auto` + changes nothing here. +- **Conversation state lives on GitHub + Mind, never here.** Labels + (`needs-info`, `pending-release`) and the issue thread itself are the + conversation's memory; in-flight dev state is the `user-facing: true` entry + in `PyAutoMind/active.md`. The conductor owns no registry, no cache, no + paired repo. +- **Delegate the conversation's dev half.** Actionable issues route into + `/start_dev_for_user`, which already owns the receipt comment, the + clarification gate, the plan comment and the milestone cadence + (~5 milestones for bugs, ~4 for features โ€” see its `reference.md`). This + conductor never re-implements those templates. +- **Stdlib / bash only** โ€” like every conductor, it must never drag the + science stack into the Brain. + +## Boundaries + +- **vs the Workspace Agent (the Voice)** โ€” split by *direction of speech*. The + Voice is expressive: it plans how the organism speaks to practitioners and + learners through authored examples. The Ears are receptive: they hear what + individual outsiders say back (issues, requests, bug reports) and hold up + the organism's end of the conversation. +- **vs intake** โ€” intake conceives tasks from the *developer's* raw ideas and + files Mind prompts; community converses with an *external reporter* whose + issue already exists on GitHub. When a community conversation yields work, + it routes via `start_dev_for_user` (issue-first), not intake (prompt-first). +- **vs start_dev_for_user** โ€” that skill is the dev-flow entry for one + already-actionable issue. Community is the layer above: discovery (scan), + assessment (triage), the ask-for-more conversation, and the ongoing + reporter-facing updates after routing. +- **vs bug / feature** โ€” they classify and plan the *work*; community manages + the *relationship* with the person who reported it. +- **vs health / hygiene / release** โ€” no verdicts, no upkeep, no releases; + external PRs and review requests are out of scope for v1 (a staged + follow-up recorded here). + +## Capability audit โ€” what the modes read + +- **GitHub search** (`gh api search/issues`): `org:PyAutoLabs` plus the + non-org homes from `repos.yaml`, `is:issue is:open -author:`, bot + authors post-filtered. +- **Issue comments** (`gh api repos///issues//comments`): last-actor + detection for awaiting-response (capped at 30 issues per scan) and the + triage comment tail. +- **PyAutoMind `repos.yaml`**: the body map, parsed for `github:` homes + (regex, stdlib-only โ€” the Brain takes no yaml dependency). diff --git a/agents/conductors/community/_community.py b/agents/conductors/community/_community.py new file mode 100644 index 0000000..0411a02 --- /dev/null +++ b/agents/conductors/community/_community.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""agents/conductors/community/_community.py โ€” core for the Community Agent. + +The Ears โ€” the organism's receptive language function (Wernicke to the +Workspace Agent's Broca/Voice: that agent speaks through examples; this one +hears the community). It reads the outside world's GitHub issues and emits +deterministic surfaces the /community skill reasons over: + + scan every repos.yaml repo -> open issues raised by non-self humans, + with awaiting-response detection and waiting-time ranking + (the /wake_up community sensory leg) + triage one issue -> context-sufficiency signals + routing surface + (the judgment โ€” actionable vs ask-for-more โ€” stays in the session) + +The conductor NEVER posts, labels or edits anything on GitHub and never writes +files. Every outward message is drafted in the /community skill session and +gated on the human; dev work routes through /start_dev_for_user. + +Stdlib-only. GitHub access is the `gh` CLI (override with COMMUNITY_GH for +hermetic tests). Exit codes: 0 surface emitted ยท 4 inputs unresolvable ยท +5 bad usage. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +PYAUTO_ROOT = Path(os.environ.get("PYAUTO_ROOT", Path.home() / "Code" / "PyAutoLabs")) +GH = os.environ.get("COMMUNITY_GH", "gh") +SELF_LOGINS = [ + s.strip() + for s in os.environ.get("COMMUNITY_SELF", "Jammy2211").split(",") + if s.strip() +] +PRIMARY_ORG = "PyAutoLabs" +SCAN_DETAIL_CAP = 30 # issues that get a per-issue last-commenter lookup + +# Context signals a well-formed report tends to carry. Each is (key, ask) โ€” +# the ask is the clarifying-question seed the skill session redrafts in its +# own words when the signal is missing. +TRIAGE_SIGNALS = ( + ("code_block", "a runnable snippet or the script that triggers this"), + ("traceback", "the full traceback / error output"), + ("version", "the installed PyAuto* versions (e.g. `pip show autolens`)"), + ("expected_vs_actual", "what you expected to happen vs what actually happened"), + ("data_pointer", "a pointer to (or description of) the dataset involved"), +) + + +def fail(code, msg): + print(f"community: {msg}", file=sys.stderr) + sys.exit(code) + + +def gh_json(args): + """Run `gh api ...` and parse JSON; None on any failure (the surface + degrades honestly rather than inventing content).""" + try: + r = subprocess.run( + [GH, "api", *args], capture_output=True, text=True, timeout=120 + ) + except (OSError, subprocess.TimeoutExpired) as e: + fail(4, f"cannot run '{GH} api': {e}") + if r.returncode != 0: + return None + try: + return json.loads(r.stdout) + except json.JSONDecodeError: + return None + + +def repo_homes(): + """Every `github:` home in PyAutoMind/repos.yaml (regex, stdlib-only โ€” + no yaml dependency in the Brain).""" + body_map = PYAUTO_ROOT / "PyAutoMind" / "repos.yaml" + if not body_map.is_file(): + fail(4, f"body map not found: {body_map} (set PYAUTO_ROOT)") + homes = re.findall( + r"^\s+github:\s*(\S+)\s*$", body_map.read_text(encoding="utf-8"), re.M + ) + if not homes: + fail(4, f"no `github:` entries parsed from {body_map}") + return homes + + +def is_bot(user): + login = (user or {}).get("login", "") + return (user or {}).get("type") == "Bot" or login.endswith("[bot]") + + +def is_self(user): + return (user or {}).get("login") in SELF_LOGINS + + +def days_since(iso): + try: + then = datetime.fromisoformat(iso.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + return round((datetime.now(timezone.utc) - then).total_seconds() / 86400, 1) + + +def search_external_issues(qualifier): + q = f"{qualifier} is:issue is:open " + " ".join( + f"-author:{login}" for login in SELF_LOGINS + ) + data = gh_json(["-X", "GET", "search/issues", "-f", f"q={q}", "-f", "per_page=50"]) + if data is None: + return None + return data.get("items", []) + + +def last_commenter(owner_repo, number): + """Login of the newest comment's author; None when uncommented/unreadable.""" + comments = gh_json([f"repos/{owner_repo}/issues/{number}/comments", "-f", "per_page=100"]) + if not comments: + return None + return (comments[-1].get("user") or {}).get("login") + + +def issue_repo(item): + # search/issues items carry the repo only inside repository_url. + return "/".join(item.get("repository_url", "").split("/")[-2:]) + + +def build_scan(): + homes = repo_homes() + extra = [h for h in homes if not h.startswith(f"{PRIMARY_ORG}/")] + + items, degraded = [], [] + org_items = search_external_issues(f"org:{PRIMARY_ORG}") + if org_items is None: + degraded.append(f"org:{PRIMARY_ORG} search failed (gh auth? rate limit?)") + else: + items += org_items + if extra: + extra_items = search_external_issues(" ".join(f"repo:{h}" for h in extra)) + if extra_items is None: + degraded.append("non-org repo search failed") + else: + items += extra_items + + issues = [] + for item in items: + if is_bot(item.get("user")): + continue + repo = issue_repo(item) + entry = { + "repo": repo, + "number": item.get("number"), + "title": item.get("title", ""), + "author": (item.get("user") or {}).get("login"), + "url": item.get("html_url"), + "labels": [l.get("name") for l in item.get("labels", [])], + "comments": item.get("comments", 0), + "updated_at": item.get("updated_at"), + "waiting_days": days_since(item.get("updated_at")), + "last_actor": None, + "awaiting_response": None, + } + issues.append(entry) + + # Awaiting-response = the conversation's last word is not ours. Cap the + # per-issue lookups; uncapped entries keep awaiting_response=None (unknown). + for entry in sorted(issues, key=lambda e: e["updated_at"] or "", reverse=True)[ + :SCAN_DETAIL_CAP + ]: + actor = ( + entry["author"] + if entry["comments"] == 0 + else last_commenter(entry["repo"], entry["number"]) + ) + entry["last_actor"] = actor + entry["awaiting_response"] = actor is not None and actor not in SELF_LOGINS + + awaiting = [e for e in issues if e["awaiting_response"]] + awaiting.sort(key=lambda e: e["waiting_days"] or 0, reverse=True) + return { + "self_logins": SELF_LOGINS, + "org": PRIMARY_ORG, + "extra_repos": extra, + "open_external_issues": issues, + "awaiting_response": awaiting, + "counts": { + "open_external": len(issues), + "awaiting_response": len(awaiting), + }, + "degraded": degraded, + "next_action": ( + "pick an issue -> `community triage ` -> the /community session " + "assesses context, drafts the reply for human approval, and routes " + "actionable work via /start_dev_for_user; this surface posts nothing" + ), + } + + +def print_scan(s): + print("== CommunityScan โ€” the Ears (reads only; posts nothing) ==") + print(f"Self logins: {', '.join(s['self_logins'])}") + print(f"Searched: org:{s['org']}" + + (f" + {len(s['extra_repos'])} non-org repo(s)" if s["extra_repos"] else "")) + for d in s["degraded"]: + print(f"DEGRADED: {d}") + c = s["counts"] + print(f"Open external issues: {c['open_external']} (awaiting our response: {c['awaiting_response']})") + for e in s["awaiting_response"]: + days = f"{e['waiting_days']:.0f}d" if e["waiting_days"] is not None else "?" + print(f" ! {e['repo']}#{e['number']} [{days} waiting] @{e['author']}: {e['title'][:70]}") + for e in s["open_external_issues"]: + if not e["awaiting_response"]: + state = "ours-to-watch" if e["awaiting_response"] is False else "unchecked" + print(f" - {e['repo']}#{e['number']} ({state}) @{e['author']}: {e['title'][:70]}") + print(f"Next action: {s['next_action']}") + + +def parse_issue_ref(ref): + m = re.match(r"https?://github\.com/([^/]+/[^/]+)/issues/(\d+)", ref) + if m: + return m.group(1), int(m.group(2)) + m = re.match(r"([^/#\s]+/[^/#\s]+)#(\d+)$", ref) + if m: + return m.group(1), int(m.group(2)) + fail(5, f"cannot parse issue ref '{ref}' โ€” use a full URL or owner/repo#N") + + +def build_triage(ref): + owner_repo, number = parse_issue_ref(ref) + issue = gh_json([f"repos/{owner_repo}/issues/{number}"]) + if issue is None: + fail(4, f"cannot fetch {owner_repo}#{number} (gh auth? does it exist?)") + body = issue.get("body") or "" + + low = body.lower() + present = { + "code_block": "```" in body, + "traceback": "traceback (most recent call last)" in low or "error:" in low, + "version": bool(re.search(r"version|(\bauto(lens|fit|galaxy|array)\S*\s*==)", low)), + "expected_vs_actual": bool(re.search(r"expect|should\b|instead\b", low)), + "data_pointer": bool(re.search(r"\.fits\b|zenodo|drive\.google|dataset", low)), + } + missing = [ + {"signal": key, "ask": ask} + for key, ask in TRIAGE_SIGNALS + if not present[key] + ] + + comments = gh_json([f"repos/{owner_repo}/issues/{number}/comments", "-f", "per_page=100"]) or [] + tail = [ + { + "author": (c.get("user") or {}).get("login"), + "created_at": c.get("created_at"), + "excerpt": (c.get("body") or "")[:400], + } + for c in comments[-3:] + ] + last = tail[-1]["author"] if tail else (issue.get("user") or {}).get("login") + + return { + "repo": owner_repo, + "number": number, + "url": issue.get("html_url"), + "title": issue.get("title", ""), + "author": (issue.get("user") or {}).get("login"), + "author_is_external": not is_self(issue.get("user")), + "state": issue.get("state"), + "labels": [l.get("name") for l in issue.get("labels", [])], + "body": body, + "signals_present": present, + "signals_missing": missing, + "comment_tail": tail, + "awaiting_response": last not in SELF_LOGINS, + "route": f"/start_dev_for_user {issue.get('html_url')}", + "reminders": [ + "the session judges sufficiency โ€” these signals are heuristics, not a verdict", + "every outward comment is drafted and shown to the human before posting", + "actionable -> /start_dev_for_user (it owns receipt/plan/milestone comments); " + "unclear -> one consolidated clarifying comment + needs-info label", + "update cadence: ~5 milestones for bugs, ~4 for features", + ], + } + + +def print_triage(t): + print(f"== CommunityTriage โ€” {t['repo']}#{t['number']} ==") + print(f"Title: {t['title']}") + print(f"Author: @{t['author']}" + + (" (external)" if t["author_is_external"] else " (self)")) + print(f"State: {t['state']} Labels: {', '.join(t['labels']) or '(none)'}") + print(f"Awaiting response: {t['awaiting_response']}") + print("Context signals:") + for key, ok in t["signals_present"].items(): + print(f" {'+' if ok else '-'} {key}") + if t["signals_missing"]: + print("Missing (clarifying-question seeds โ€” redraft in the session's words):") + for m in t["signals_missing"]: + print(f" ? {m['ask']}") + if t["comment_tail"]: + print("Comment tail:") + for c in t["comment_tail"]: + print(f" @{c['author']} ({c['created_at']}): {c['excerpt'][:100]}") + print(f"Route: {t['route']}") + for r in t["reminders"]: + print(f"Reminder: {r}") + + +def main(): + argv = sys.argv[1:] + parser = argparse.ArgumentParser(prog="community", description=__doc__) + parser.add_argument("mode", nargs="?", default="scan", + help="scan (default) | triage ") + parser.add_argument("ref", nargs="?", default=None, + help="triage only: issue URL or owner/repo#N") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + if args.mode == "scan": + surface = build_scan() + print(json.dumps(surface, indent=2)) if args.json else print_scan(surface) + elif args.mode == "triage": + if not args.ref: + fail(5, "triage needs an issue ref โ€” a full URL or owner/repo#N") + surface = build_triage(args.ref) + print(json.dumps(surface, indent=2)) if args.json else print_triage(surface) + else: + fail(5, f"unknown mode '{args.mode}' โ€” use scan or triage ") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/agents/conductors/community/community.sh b/agents/conductors/community/community.sh new file mode 100644 index 0000000..eae63a8 --- /dev/null +++ b/agents/conductors/community/community.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# agents/conductors/community/community.sh โ€” the Community Agent (a PyAutoBrain +# reasoning conductor). Organism-facing name: the Ears โ€” the organism's +# receptive language function: it hears the community (user-filed GitHub +# issues) and drafts what the organism says back; the human remains the mouth. +# (Wernicke to the Workspace Agent's Broca โ€” that Voice speaks through +# examples; this agent comprehends and converses.) +# +# The CLI emits deterministic, read-only surfaces; the conversational judgment +# (actionable vs ask-for-more, the reply prose) lives in the /community skill +# session, and every outward message is gated on the human. It never posts, +# labels, edits or writes anything. +# +# Usage: +# community.sh # scan (default): open external issues, +# # awaiting-response ranking +# community.sh scan # same, explicit +# community.sh triage # one issue -> context-sufficiency surface +# # ( = URL or owner/repo#N) +# community.sh ... --json # machine-readable output + +set -uo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +source "$HERE/../../_common.sh" + +exec python3 "$HERE/_community.py" "$@" diff --git a/bin/pyauto-brain b/bin/pyauto-brain index c2fb458..91288f8 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -12,6 +12,7 @@ # CONSULT (they only opine; never dispatch or # mutate). Runnable directly for a quick read. # +# pyauto-brain community [args] (conductor) the Ears: scan/triage user-filed issues across the repos โ€” read-only surfaces, replies stay human-gated # pyauto-brain feature [args] (conductor) reason over PyAutoMind tasks, plan growth # pyauto-brain refactor [args] (conductor) plan behaviour-preserving restructuring (default-safe) # pyauto-brain workspace [args] (conductor) the Voice: plan/survey workspace + HowTo example authorship โ€” WorkspaceDecision (writes nothing) @@ -47,6 +48,7 @@ FACULTIES_DIR="$AGENTS_DIR/faculties" declare -A AGENT_SCRIPT=( [intake]="$CONDUCTORS_DIR/intake/intake.sh" + [community]="$CONDUCTORS_DIR/community/community.sh" [feature]="$CONDUCTORS_DIR/feature/feature.sh" [bug]="$CONDUCTORS_DIR/bug/bug.sh" [refactor]="$CONDUCTORS_DIR/refactor/refactor.sh" @@ -64,6 +66,7 @@ declare -A AGENT_SCRIPT=( ) declare -A AGENT_DESC=( [intake]="Conceive a task: turn raw input into a formal, headed PyAutoMind prompt (files it; never starts dev)" + [community]="The ears โ€” the organism's receptive function: scan/triage user-filed GitHub issues across the repos; drafts stay human-gated, dev work routes via start_dev_for_user (never posts)" [feature]="Reason over PyAutoMind feature tasks: select, size, phase, plan for start_dev" [bug]="The immune system: classify a bug/regression/Heart finding, locate the fix, plan the repair" [refactor]="The renewal function: plan behaviour-preserving restructuring โ€” RefactorDecision; default-safe under --auto" @@ -81,7 +84,7 @@ declare -A AGENT_DESC=( ) # Conductors are the front doors a human drives; faculties are consulted (and # runnable read-only). Both are dispatchable, but the menu groups them by tier. -CONDUCTOR_ORDER=(intake feature bug refactor workspace profiling hygiene clone build release health) +CONDUCTOR_ORDER=(intake community feature bug refactor workspace profiling hygiene clone build release health) FACULTY_ORDER=(vitals review memory samplers) AGENT_ORDER=("${CONDUCTOR_ORDER[@]}" "${FACULTY_ORDER[@]}") diff --git a/skills/COMMANDS.md b/skills/COMMANDS.md index 40728b8..d51768e 100644 --- a/skills/COMMANDS.md +++ b/skills/COMMANDS.md @@ -1,6 +1,6 @@ # PyAutoBrain command surface โ€” shared reference -The short verb commands (`/intake`, `/feature`, `/build`, `/health`, `/bug`, +The short verb commands (`/intake`, `/community`, `/feature`, `/build`, `/health`, `/bug`, `/refactor`, `/workspace`, `/profiling`, `/hygiene`, `/docs`, `/research`, `/route`, `/brain`) are a thin, human-friendly **veneer** over the PyAutoBrain router (`bin/pyauto-brain`). This file is the shared context every command file points at, so each command body stays a few lines long. @@ -30,6 +30,7 @@ readiness gate, or execution โ€” those belong to the organs. | Command | Agent | Chain | |---------|-------|-------| | `/intake` | Intake Agent | `bin/pyauto-brain intake` โ†’ files a PyAutoMind prompt (**before** `start_dev`); `census`/`dashboard` = backlog inventory / `PyAutoMind/dashboard.md` | +| `/community` | Community Agent | `bin/pyauto-brain community` โ†’ scan/triage user-filed issues (read-only surfaces) โ†’ session drafts replies for human approval โ†’ `/start_dev_for_user` | | `/feature` | Feature Agent | `bin/pyauto-brain feature` โ†’ `start_dev` โ†’ `ship_*` | | `/bug` | Bug Agent | `bin/pyauto-brain bug` โ†’ `start_dev` โ†’ `ship_*` (health mode โ†’ vitals + Heart issues) | | `/refactor` | Refactor Agent | `bin/pyauto-brain refactor` โ†’ `start_dev [--auto]` โ†’ `ship_*` (behaviour-preserving; default-safe) | @@ -76,8 +77,9 @@ call, so the Brain is not bypassed): (`bin/clean_slate.sh`) โ†’ consult **`/health`** + **`/hygiene`**. **Everywhere (gh-API, so it runs on mobile Claude Code chat / Codex too):** an overnight scheduled-run sweep (`bin/overnight_status.sh`), a version-pin drift check - (`bin/version_drift.sh`), and resume-context (in-flight work + pending-release - PRs) โ†’ one prioritized digest. Auto-runs only the non-destructive steps; + (`bin/version_drift.sh`), a community scan (`bin/pyauto-brain community scan` + โ€” external users awaiting a response), and resume-context (in-flight work + + pending-release PRs) โ†’ one prioritized digest. Auto-runs only the non-destructive steps; surfaces destructive cleanup for approval; on mobile/codex it skips the local-only steps. Interactive/terminal only (the automated morning webhooks are separate). diff --git a/skills/community/SKILL.md b/skills/community/SKILL.md new file mode 100644 index 0000000..27555d6 --- /dev/null +++ b/skills/community/SKILL.md @@ -0,0 +1,13 @@ +--- +name: community +description: Hear and answer the PyAuto community through the PyAutoBrain Community Agent โ€” scan every repo for user-filed issues awaiting a response, triage one issue's context sufficiency, then draft human-approved replies and route actionable work via start_dev_for_user. Use when responding to external users' GitHub issues or checking what the community is waiting on. +--- + +# Community + +Follow [`community.md`](community.md) exactly. The Community Agent (the +*Ears* โ€” the organism's receptive language function; the Workspace Agent's +*Voice* speaks, this listens) emits read-only scan/triage surfaces; the +session drafts every outward reply for human approval and routes dev work +through `start_dev_for_user`. The conductor never posts, and conversation +state stays on GitHub + `active.md`. diff --git a/skills/community/community.md b/skills/community/community.md new file mode 100644 index 0000000..3343c38 --- /dev/null +++ b/skills/community/community.md @@ -0,0 +1,58 @@ +# /community โ€” hear and answer the community (via the Brain Community Agent) + +Manage the organism's conversations with **external users** โ€” the *Ears* โ€” via +PyAutoBrain's **Community Agent**. You never name the Brain; this command is +the door. The agent hears (scan/triage are read-only surfaces); **you draft +every reply and the human approves it before anything is posted**. + +Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. The agent's full +docs: `bin/pyauto-brain help community`. + +## Do + +1. **Scan** โ€” who is waiting on us? + + ```bash + bin/pyauto-brain community # default mode + ``` + + Emits a **CommunityScan**: open issues authored by non-self humans across + every `repos.yaml` repo, with awaiting-response detection (the + conversation's last word is not ours) ranked by waiting time. `/wake_up` + runs this same scan as its community step. + +2. **Triage** the issue the human picks: + + ```bash + bin/pyauto-brain community triage + ``` + + Emits context-sufficiency signals (code block, traceback, versions, + expected-vs-actual, data pointer), clarifying-question seeds for whatever + is missing, and the comment tail. The signals are heuristics โ€” **you** read + the actual issue and judge. + +3. **Converse โ€” drafts only.** Based on your judgment: + - **Actionable** โ†’ route into `/start_dev_for_user ` โ€” it owns the + receipt comment, the clarification gate, the plan comment and the + milestone cadence. Do not re-implement its templates here. + - **Not yet actionable** โ†’ draft **one consolidated clarifying comment** + in a warm teammate tone (seeds from the triage surface, redrafted in your + own words, reporter @-mentioned), present it to the human, and only post + after approval; label `needs-info`. + - **In-flight follow-ups** (reporter replied, milestone reached) โ†’ draft + the update the same way; `/update_issue` posts progress from a dev + session. Cadence: ~5 milestones for bugs, ~4 for features. + +## Boundary + +- **Hears and drafts; the human speaks.** Every outward message is presented + for approval before posting โ€” at every autonomy level; `--auto` changes + nothing here. +- **No new state.** The issue thread + labels are the conversation's memory; + in-flight dev state is the `user-facing: true` entry in + `PyAutoMind/active.md`. +- **vs `/workspace` (the Voice):** that agent plans how the organism speaks + through authored examples; this one holds up its end of a conversation with + a specific outsider. +- `--json` gives the machine-readable scan/triage surface. diff --git a/skills/wake_up/wake_up.md b/skills/wake_up/wake_up.md index 05a152e..58ef61c 100644 --- a/skills/wake_up/wake_up.md +++ b/skills/wake_up/wake_up.md @@ -52,14 +52,21 @@ Run in order, then emit the digest. conclusions are the readiness/release signal. 5. **Version drift** โ€” `bash PyAutoBrain/bin/version_drift.sh`: version-stamp consistency across libs + workspaces vs the latest release tag. -6. **Resume context** โ€” pick up where you left off: in-flight / parked / queued +6. **Community** โ€” `bin/pyauto-brain community scan`: open issues raised by + **other humans** (not you) across every repo, with awaiting-response + ranking. Surface who is waiting and for how long; respond via + **`/community`** (drafts are human-approved there โ€” never auto-reply from + the digest). +7. **Resume context** โ€” pick up where you left off: in-flight / parked / queued work (`PyAutoMind/active.md`, `parked.md`, `queue.md`) + open **pending-release PRs** (`gh`); **locally** also worktrees with unpushed commits. -7. **Hygiene** *(local)* โ€” consult **`/hygiene`** for cleanup candidates. +8. **Hygiene** *(local)* โ€” consult **`/hygiene`** for cleanup candidates. ### Digest Emit one prioritized card: - ๐Ÿšจ **Blocking** โ€” release blocked, RED readiness, failing overnight jobs / CI. +- ๐Ÿ’ฌ **Community** โ€” external users awaiting a response (issue, author, days + waiting); point at **`/community`**, never draft replies in the digest. - โš ๏ธ **Drifted** โ€” off-main / behind repos, version-pin mismatches. - ๐Ÿ”„ **Resume** โ€” in-flight / parked tasks, open pending-release PRs. - ๐Ÿงน **Cleanable** โ€” cleanup surfaced *for approval*: list it, never auto-act. diff --git a/tests/test_community_conductor.py b/tests/test_community_conductor.py new file mode 100644 index 0000000..6c3bdc0 --- /dev/null +++ b/tests/test_community_conductor.py @@ -0,0 +1,187 @@ +"""Contract tests for the community conductor's CLI footing. + +Hermetic: PYAUTO_ROOT points at a fabricated PyAutoMind/repos.yaml and +COMMUNITY_GH at a stub `gh` that serves fixture JSON, so scan/triage surfaces +are asserted structurally with no network and no real checkouts. The conductor +is read-only โ€” it never posts to GitHub (the stub records every call so the +tests can prove no mutating endpoint is ever hit) and never writes files. +""" + +import json +import os +import stat +import subprocess +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +BRAIN = BRAIN_HOME / "bin" / "pyauto-brain" + +SCAN_KEYS = { + "self_logins", "org", "extra_repos", "open_external_issues", + "awaiting_response", "counts", "degraded", "next_action", +} +TRIAGE_KEYS = { + "repo", "number", "url", "title", "author", "author_is_external", "state", + "labels", "body", "signals_present", "signals_missing", "comment_tail", + "awaiting_response", "route", "reminders", +} + +REPOS_YAML = """\ +repos: + PyAutoLens: + github: PyAutoLabs/PyAutoLens + category: library + admin_jammy: + github: Jammy2211/admin_jammy + category: admin +""" + + +def _issue(repo, number, login, title, comments=0, user_type="User"): + return { + "number": number, + "title": title, + "user": {"login": login, "type": user_type}, + "html_url": f"https://github.com/{repo}/issues/{number}", + "labels": [], + "comments": comments, + "updated_at": "2026-07-10T00:00:00Z", + "repository_url": f"https://api.github.com/repos/{repo}", + } + + +def _fabricate(tmp_path, fixtures): + """A PYAUTO_ROOT with a body map, plus a stub gh serving per-endpoint + fixture JSON and logging every invocation to gh_calls.log.""" + mind = tmp_path / "PyAutoMind" + mind.mkdir() + (mind / "repos.yaml").write_text(REPOS_YAML) + + fixture_dir = tmp_path / "fixtures" + fixture_dir.mkdir() + for name, payload in fixtures.items(): + (fixture_dir / name).write_text(json.dumps(payload)) + + stub = tmp_path / "gh" + stub.write_text(f"""#!/usr/bin/env bash +echo "$@" >> "{tmp_path}/gh_calls.log" +for arg in "$@"; do + case "$arg" in + q=org:*) cat "{fixture_dir}/search_org.json"; exit 0 ;; + q=repo:*) cat "{fixture_dir}/search_extra.json"; exit 0 ;; + */comments) cat "{fixture_dir}/comments.json"; exit 0 ;; + repos/*/issues/*) cat "{fixture_dir}/issue.json"; exit 0 ;; + esac +done +exit 1 +""") + stub.chmod(stub.stat().st_mode | stat.S_IEXEC) + return stub + + +def _run(args, tmp_path, stub): + env = {**os.environ, "PYAUTO_ROOT": str(tmp_path), "COMMUNITY_GH": str(stub)} + return subprocess.run( + [str(BRAIN), "community", *args], + capture_output=True, text=True, env=env, cwd=tmp_path, + ) + + +def test_scan_json_is_a_complete_surface_and_filters_bots(tmp_path): + stub = _fabricate(tmp_path, { + "search_org.json": {"items": [ + _issue("PyAutoLabs/PyAutoLens", 1, "some_user", "lens model crashes"), + _issue("PyAutoLabs/PyAutoLens", 2, "dependabot[bot]", "bump", user_type="Bot"), + ]}, + "search_extra.json": {"items": []}, + "comments.json": [], + }) + r = _run(["scan", "--json"], tmp_path, stub) + assert r.returncode == 0, r.stderr + s = json.loads(r.stdout) + assert set(s) == SCAN_KEYS + assert s["org"] == "PyAutoLabs" + assert s["extra_repos"] == ["Jammy2211/admin_jammy"] + assert [e["author"] for e in s["open_external_issues"]] == ["some_user"] + assert s["counts"] == {"open_external": 1, "awaiting_response": 1} + # Uncommented external issue: the author had the last word -> awaiting us. + assert s["awaiting_response"][0]["last_actor"] == "some_user" + + +def test_scan_last_word_ours_is_not_awaiting(tmp_path): + stub = _fabricate(tmp_path, { + "search_org.json": {"items": [ + _issue("PyAutoLabs/PyAutoLens", 3, "some_user", "feature request", comments=2), + ]}, + "search_extra.json": {"items": []}, + "comments.json": [ + {"user": {"login": "some_user"}, "created_at": "t1", "body": "ping"}, + {"user": {"login": "Jammy2211"}, "created_at": "t2", "body": "on it"}, + ], + }) + s = json.loads(_run(["scan", "--json"], tmp_path, stub).stdout) + assert s["counts"]["awaiting_response"] == 0 + assert s["open_external_issues"][0]["awaiting_response"] is False + assert s["open_external_issues"][0]["last_actor"] == "Jammy2211" + + +def test_triage_surface_signals_and_missing_asks(tmp_path): + body = "```python\nfit = al.FitImaging(...)\n```\nTraceback (most recent call last):\nboom" + stub = _fabricate(tmp_path, { + "search_org.json": {"items": []}, + "search_extra.json": {"items": []}, + "issue.json": { + "number": 7, "title": "crash", "state": "open", "body": body, + "user": {"login": "some_user", "type": "User"}, + "html_url": "https://github.com/PyAutoLabs/PyAutoLens/issues/7", + "labels": [{"name": "bug"}], + }, + "comments.json": [], + }) + r = _run(["triage", "PyAutoLabs/PyAutoLens#7", "--json"], tmp_path, stub) + assert r.returncode == 0, r.stderr + t = json.loads(r.stdout) + assert set(t) == TRIAGE_KEYS + assert t["author_is_external"] is True + assert t["signals_present"]["code_block"] is True + assert t["signals_present"]["traceback"] is True + missing = {m["signal"] for m in t["signals_missing"]} + assert "version" in missing and "data_pointer" in missing + assert t["route"].startswith("/start_dev_for_user ") + + +def test_never_writes_and_never_mutates_github(tmp_path): + stub = _fabricate(tmp_path, { + "search_org.json": {"items": []}, + "search_extra.json": {"items": []}, + "comments.json": [], + }) + before = {p for p in tmp_path.rglob("*")} + r = _run(["scan", "--json"], tmp_path, stub) + assert r.returncode == 0, r.stderr + after = {p for p in tmp_path.rglob("*")} + assert after - before == {tmp_path / "gh_calls.log"} + calls = (tmp_path / "gh_calls.log").read_text() + assert "-X POST" not in calls and "-X PATCH" not in calls and "-X DELETE" not in calls + # Every call is `gh api` reads driven through the one entry point. + assert all(line.startswith("api ") for line in calls.strip().splitlines()) + + +def test_bad_ref_and_unknown_mode_fail_loudly(tmp_path): + stub = _fabricate(tmp_path, { + "search_org.json": {"items": []}, + "search_extra.json": {"items": []}, + "comments.json": [], + }) + assert _run(["triage", "not-a-ref"], tmp_path, stub).returncode == 5 + assert _run(["triage"], tmp_path, stub).returncode == 5 + assert _run(["gossip"], tmp_path, stub).returncode == 5 + + +def test_scan_degrades_honestly_when_search_fails(tmp_path): + stub = _fabricate(tmp_path, {"comments.json": []}) # no search fixtures -> stub exits 1 + r = _run(["scan", "--json"], tmp_path, stub) + assert r.returncode == 0, r.stderr + s = json.loads(r.stdout) + assert s["counts"]["open_external"] == 0 + assert len(s["degraded"]) == 2