Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ the `/<verb>` 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` |
| `community` | The ears — the organism's receptive function: scan/triage user-filed GitHub issues + PRs and review requests 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` |
Expand Down
38 changes: 24 additions & 14 deletions agents/conductors/community/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

> **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
> GitHub issues and pull requests 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
Expand All @@ -27,20 +28,22 @@ 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 <issue-ref>` | 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 |
| `scan` *(default)* | every `PyAutoMind/repos.yaml` repo → open issues **and PRs** authored by non-self humans (bots filtered), with **awaiting-response** detection (the conversation's last word is not ours) ranked by waiting time, plus open PRs with **review requested** from a self login (any author) | `/community` step 1; the `/wake_up` community sensory leg |
| `triage <ref>` | one issue or PR → context-sufficiency signals (code block, traceback, versions, expected-vs-actual, data pointer), missing-signal clarifying-question seeds, comment tail, route; a PR ref adds the **change-shape block** (draft, files, +/-, requested reviewers, mergeable state, head→base) | `/community` steps 2–3 |

```
pyauto-brain community # scan: who is waiting on us?
pyauto-brain community scan --json
pyauto-brain community triage <url | owner/repo#N> [--json]
pyauto-brain community triage <issue-or-PR url | owner/repo#N> [--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.
tests), `COMMUNITY_SELF` the self logins (default `Jammy2211`),
`COMMUNITY_SEARCH_PAUSE` the inter-search sleep (default 2s — the scan makes
up to six search calls and GitHub's secondary rate limit trips on bursts). A
failed search degrades honestly (`degraded:` in the surface), never silently.

## Fundamental principles

Expand Down Expand Up @@ -79,17 +82,24 @@ search degrades honestly (`degraded:` in the surface), never silently.
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).
- **vs health / hygiene / release** — no verdicts, no upkeep, no releases.
- **vs the review faculty** — an external PR surfaced here routes to a
*human review with session-drafted comments*; the review faculty judges
only our own feature branches for the autonomous-ship gate, never
community PRs. Known v2 limit: awaiting-response reads PR *conversation*
comments — review-thread comments don't count as our reply yet.

## 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:<self>`, bot
authors post-filtered.
- **Issue comments** (`gh api repos/<o>/<r>/issues/<n>/comments`): last-actor
detection for awaiting-response (capped at 30 issues per scan) and the
triage comment tail.
non-org homes from `repos.yaml`; three passes per qualifier group —
`is:issue is:open -author:<self>`, `is:pr is:open -author:<self>`, and
`is:pr is:open review-requested:<self>` — bot authors post-filtered on the
external passes.
- **Issue/PR conversation comments** (`gh api repos/<o>/<r>/issues/<n>/comments`):
last-actor detection for awaiting-response (capped at 30 items per scan)
and the triage comment tail.
- **Pull detail** (`gh api repos/<o>/<r>/pulls/<n>`): the triage change-shape
block for PR refs.
- **PyAutoMind `repos.yaml`**: the body map, parsed for `github:` homes
(regex, stdlib-only — the Brain takes no yaml dependency).
184 changes: 130 additions & 54 deletions agents/conductors/community/_community.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
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
scan every repos.yaml repo -> open issues AND pull requests raised by
non-self humans (awaiting-response detection, waiting-time
ranking) + open PRs with review requested from a self login
(the /wake_up community sensory leg)
triage one issue -> context-sufficiency signals + routing surface
triage one issue or PR -> context-sufficiency signals + routing surface;
PR refs additionally carry the change-shape block (draft, files,
additions/deletions, requested reviewers, mergeable state)
(the judgment — actionable vs ask-for-more — stays in the session)

The conductor NEVER posts, labels or edits anything on GitHub and never writes
Expand All @@ -29,6 +32,7 @@
import re
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

Expand All @@ -41,6 +45,9 @@
]
PRIMARY_ORG = "PyAutoLabs"
SCAN_DETAIL_CAP = 30 # issues that get a per-issue last-commenter lookup
# Pause between search-API calls — the scan makes up to six, and GitHub's
# secondary rate limit trips on rapid bursts (hermetic tests set it to 0).
SEARCH_PAUSE_S = float(os.environ.get("COMMUNITY_SEARCH_PAUSE", "2"))

# 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
Expand Down Expand Up @@ -107,16 +114,25 @@ def days_since(iso):
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
)
def search_open(qualifier, kind, extra_quals=""):
"""Open issues or PRs (`kind` = issue|pr) matching the qualifier; None on
a failed search."""
q = f"{qualifier} is:{kind} is:open"
if extra_quals:
q += f" {extra_quals}"
if SEARCH_PAUSE_S:
time.sleep(SEARCH_PAUSE_S)
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 search_external(qualifier, kind):
not_self = " ".join(f"-author:{login}" for login in SELF_LOGINS)
return search_open(qualifier, kind, not_self)


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"])
Expand All @@ -130,48 +146,62 @@ def issue_repo(item):
return "/".join(item.get("repository_url", "").split("/")[-2:])


def _entry(item, kind):
return {
"type": kind,
"repo": issue_repo(item),
"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,
}


def _searched(qualifiers, kind, degraded, external=True):
"""Run one search per qualifier group, folding failures into `degraded`."""
entries = []
tag = kind if external else "review-requested"
for label, qualifier in qualifiers:
items = (
search_external(qualifier, kind)
if external
else search_open(qualifier, kind, "review-requested:" + SELF_LOGINS[0])
)
if items is None:
degraded.append(f"{label} {tag} search failed (gh auth? rate limit?)")
continue
entries += [_entry(i, kind) for i in items if not is_bot(i.get("user"))]
return entries


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
qualifiers = [(f"org:{PRIMARY_ORG}", f"org:{PRIMARY_ORG}")]
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)
qualifiers.append(("non-org", " ".join(f"repo:{h}" for h in extra)))

degraded = []
issues = _searched(qualifiers, "issue", degraded)
prs = _searched(qualifiers, "pr", degraded)
# Review requests target a self login regardless of author (a bot's PR
# asking for review is still ours to answer, so no external filter).
review_requested = _searched(qualifiers, "pr", degraded, external=False)

# 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
]:
# per-item lookups; uncapped entries keep awaiting_response=None (unknown).
# Uses issue-conversation comments (PR review-thread comments are a known
# v2 limit, recorded in AGENTS.md).
conversations = issues + prs
for entry in sorted(
conversations, key=lambda e: e["updated_at"] or "", reverse=True
)[:SCAN_DETAIL_CAP]:
actor = (
entry["author"]
if entry["comments"] == 0
Expand All @@ -180,21 +210,25 @@ def build_scan():
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 = [e for e in conversations 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,
"open_external_prs": prs,
"awaiting_review": review_requested,
"awaiting_response": awaiting,
"counts": {
"open_external": len(issues),
"open_external_prs": len(prs),
"awaiting_review": len(review_requested),
"awaiting_response": len(awaiting),
},
"degraded": degraded,
"next_action": (
"pick an issue -> `community triage <ref>` -> the /community session "
"pick an item -> `community triage <ref>` -> the /community session "
"assesses context, drafts the reply for human approval, and routes "
"actionable work via /start_dev_for_user; this surface posts nothing"
),
Expand All @@ -209,25 +243,52 @@ def print_scan(s):
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']})")
print(f"Open external: {c['open_external']} issue(s), {c['open_external_prs']} PR(s)"
f" (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"]:
kind = "PR " if e["type"] == "pr" else ""
print(f" ! {kind}{e['repo']}#{e['number']} [{days} waiting] @{e['author']}: {e['title'][:70]}")
for e in s["open_external_issues"] + s["open_external_prs"]:
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]}")
kind = "PR " if e["type"] == "pr" else ""
print(f" - {kind}{e['repo']}#{e['number']} ({state}) @{e['author']}: {e['title'][:70]}")
if s["awaiting_review"]:
print(f"Review requested: {c['awaiting_review']}")
for e in s["awaiting_review"]:
print(f" * {e['repo']}#{e['number']} @{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)
m = re.match(r"https?://github\.com/([^/]+/[^/]+)/(?:issues|pull)/(\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")
fail(5, f"cannot parse ref '{ref}' — use a full issue/PR URL or owner/repo#N")


def pr_block(owner_repo, number):
"""The change-shape block for a PR ref; None when the pulls endpoint is
unreadable (surface degrades, never invents)."""
pr = gh_json([f"repos/{owner_repo}/pulls/{number}"])
if pr is None:
return None
return {
"draft": pr.get("draft"),
"changed_files": pr.get("changed_files"),
"additions": pr.get("additions"),
"deletions": pr.get("deletions"),
"mergeable_state": pr.get("mergeable_state"),
"requested_reviewers": [
(u or {}).get("login") for u in pr.get("requested_reviewers", [])
],
"base": (pr.get("base") or {}).get("ref"),
"head": (pr.get("head") or {}).get("ref"),
}


def build_triage(ref):
Expand Down Expand Up @@ -262,7 +323,10 @@ def build_triage(ref):
]
last = tail[-1]["author"] if tail else (issue.get("user") or {}).get("login")

is_pr = "pull_request" in issue
return {
"type": "pr" if is_pr else "issue",
"pr": pr_block(owner_repo, number) if is_pr else None,
"repo": owner_repo,
"number": number,
"url": issue.get("html_url"),
Expand All @@ -276,7 +340,12 @@ def build_triage(ref):
"signals_missing": missing,
"comment_tail": tail,
"awaiting_response": last not in SELF_LOGINS,
"route": f"/start_dev_for_user {issue.get('html_url')}",
"route": (
f"human review of PR {issue.get('html_url')} — session drafts the "
f"review comments (this is NOT the ship-gate review faculty)"
if is_pr
else 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",
Expand All @@ -288,11 +357,18 @@ def build_triage(ref):


def print_triage(t):
print(f"== CommunityTriage — {t['repo']}#{t['number']} ==")
kind = "PR" if t["type"] == "pr" else "issue"
print(f"== CommunityTriage — {t['repo']}#{t['number']} ({kind}) ==")
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)'}")
if t["pr"]:
p = t["pr"]
reviewers = ", ".join(p["requested_reviewers"]) or "(none)"
print(f"PR shape: {p['changed_files']} file(s), +{p['additions']}/-{p['deletions']}"
f" draft={p['draft']} mergeable={p['mergeable_state']} {p['head']} -> {p['base']}")
print(f"Review requested: {reviewers}")
print(f"Awaiting response: {t['awaiting_response']}")
print("Context signals:")
for key, ok in t["signals_present"].items():
Expand All @@ -316,7 +392,7 @@ def main():
parser.add_argument("mode", nargs="?", default="scan",
help="scan (default) | triage <issue-ref>")
parser.add_argument("ref", nargs="?", default=None,
help="triage only: issue URL or owner/repo#N")
help="triage only: issue/PR URL or owner/repo#N")
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)

Expand Down
Loading