From 1bef0881096f8cb8860584ccb122a53ce38627ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 15:34:21 +0000 Subject: [PATCH] fix: conductor discovery predates lifecycle split (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature, bug and refactor selection modes all failed silently: each rooted prompt discovery at mind//, a path that stopped existing when the Mind lifecycle split (PyAutoMind#71) closed 2026-07-13 and prompts moved under draft///. 86 backlog prompts were invisible to the three front doors, and each path printed a plausible "no prompts found" rather than signalling a broken root. Unshipped residue of brain-lifecycle-path-fixes (#128), which taught the reader (parse_prompt) the draft/ layout but never the discoverer. Fixed the way #128 established — one definition in the shared sizing faculty, not three parallel patches: - _sizing.py gains discover_prompts(mind, work_type), covering the draft/ layout and legacy flat paths, excluding complete/ and READMEs. - feature/bug/refactor discoverers become thin delegations; the bug agent's ad-hoc README filter is dropped as redundant. - tests/test_conductor_discovery.py locks both layouts, the exclusions, and asserts each conductor's selection surface is non-empty on a draft/ Mind — the regression that would have caught this. - Stale Mind paths corrected in agent prose and user-facing messages. This also fixes `bug health`, which reported health-fix prompts "absent" because it probed the pre-split bug/health_fixes/ path. active/ is deliberately not discovered: it is flat and holds issued work, while selection answers "what should I start next". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NFJgweLwnDUtUxbbe5NFXn --- agents/conductors/bug/AGENTS.md | 2 +- agents/conductors/bug/BUG_TAXONOMY.md | 6 +- agents/conductors/bug/_bug.py | 13 ++- agents/conductors/bug/bug.sh | 2 +- agents/conductors/feature/AGENTS.md | 2 +- agents/conductors/feature/_feature.py | 8 +- agents/conductors/refactor/AGENTS.md | 2 +- agents/conductors/refactor/_refactor.py | 5 +- agents/faculties/sizing/_sizing.py | 35 +++++++ skills/bug/bug.md | 2 +- tests/test_conductor_discovery.py | 131 ++++++++++++++++++++++++ 11 files changed, 185 insertions(+), 23 deletions(-) create mode 100644 tests/test_conductor_discovery.py diff --git a/agents/conductors/bug/AGENTS.md b/agents/conductors/bug/AGENTS.md index 0fde620..c218d95 100644 --- a/agents/conductors/bug/AGENTS.md +++ b/agents/conductors/bug/AGENTS.md @@ -69,7 +69,7 @@ belongs in the affected repo, PyAutoHeart, PyAutoHands or PyAutoBrain: 2. the **filed PyAutoHeart issues** — `gh issue list --repo PyAutoLabs/PyAutoHeart` (`$PYAUTO_HEART_REPO` overridable), the durable findings Heart authored. -Findings hinted as real defects become `PyAutoMind/bug/health_fixes/.md` prompts +Findings hinted as real defects become `PyAutoMind/draft/bug/health_fixes/.md` prompts (its README already cites Heart issue #27); flaky/expected findings are left to the Health conductor. diff --git a/agents/conductors/bug/BUG_TAXONOMY.md b/agents/conductors/bug/BUG_TAXONOMY.md index 8c5e107..04e54de 100644 --- a/agents/conductors/bug/BUG_TAXONOMY.md +++ b/agents/conductors/bug/BUG_TAXONOMY.md @@ -6,7 +6,7 @@ belongs**, and which **health inputs** it reads. It mirrors the Feature Agent's deterministic core by import (repo/target parsing, the difficulty heuristic, PyAutoMemory routing, in-flight down-ranking all live in `_feature.py`). The Bug Agent adds only the bug-specific reasoning in `_bug.py`. Sources of truth: `PyAutoMind/ROUTING.md`, -`PyAutoBrain/AGENTS.md`, `agents/faculties/vitals/`, `PyAutoMind/bug/health_fixes/`. +`PyAutoBrain/AGENTS.md`, `agents/faculties/vitals/`, `PyAutoMind/draft/bug/health_fixes/`. ## Classification (typing the threat) @@ -79,7 +79,7 @@ re-implement a Heart check. `bug.sh health` reads two complementary signals: For each finding `_bug.py` emits a first-pass **category hint** (real-bug / config / flaky / expected, from the issue title + labels); the reasoning layer confirms it and decides where the fix belongs (affected repo, PyAutoHeart, PyAutoHands, PyAutoBrain). -Confirmed real defects become `PyAutoMind/bug/health_fixes/.md` prompts and enter +Confirmed real defects become `PyAutoMind/draft/bug/health_fixes/.md` prompts and enter the normal workflow; flaky / expected findings are left to the Health conductor's loop. Validation after patching is always the vitals faculty (`pyauto-heart readiness` GREEN, or acknowledged YELLOW), never a check re-run here. @@ -105,7 +105,7 @@ small; don't multiply faculties prematurely), with this as the documented seam. ## Boundary audit — reasoning vs. health vs. execution ``` -intent → PyAutoMind (bug/* prompts, bug/health_fixes/, active/planned state) +intent → PyAutoMind (draft/bug/* prompts, draft/bug/health_fixes/, active/planned state) reasoning → PyAutoBrain (Bug Agent — this; reuses the Feature core) knowledge → PyAutoMemory (recurring failures / prior fixes / flaky tests; cited, not invented) health → PyAutoHeart (via the vitals faculty + filed Heart issues; never re-implemented) diff --git a/agents/conductors/bug/_bug.py b/agents/conductors/bug/_bug.py index 5ce53ad..1301665 100755 --- a/agents/conductors/bug/_bug.py +++ b/agents/conductors/bug/_bug.py @@ -302,8 +302,7 @@ def _next_action(d: dict) -> str: # --- discovery + selection (bug/**) ------------------------------------------ def discover_bugs(mind: Path): - bug = mind / "bug" - return sorted(bug.rglob("*.md")) if bug.is_dir() else [] + return F.discover_prompts(mind, "bug") def _referenced_bug_paths(mind: Path): @@ -325,7 +324,7 @@ def _referenced_bug_paths(mind: Path): def select_bug(mind: Path, constraint: dict, limit: int): - prompts = [p for p in discover_bugs(mind) if p.name.lower() != "readme.md"] + prompts = discover_bugs(mind) in_flight = _referenced_bug_paths(mind) rows = [] for path in prompts: @@ -418,8 +417,8 @@ def emit_health(mode: str, verdict: str, issues: list, mind: Path): print("== BugDecision (health-issue mode) ==") print(f"Mode: {mode}") print(f"Live vitals verdict: {verdict.upper()} (consulted via the vitals faculty)") - hf = mind / "bug" / "health_fixes" - print(f"Health-fix prompts: {'present' if hf.is_dir() else 'absent'} at PyAutoMind/bug/health_fixes/") + hf = mind / "draft" / "bug" / "health_fixes" + print(f"Health-fix prompts: {'present' if hf.is_dir() else 'absent'} at PyAutoMind/draft/bug/health_fixes/") if not issues: print("Filed PyAutoHeart issues: none open (or gh unavailable).") else: @@ -429,7 +428,7 @@ def emit_health(mode: str, verdict: str, issues: list, mind: Path): print(f" - #{it.get('number')} {it.get('title','').strip()}") print(f" hint: {hint_heart_category(it)}") print("Next action: For each finding the hint marks a real defect, write a " - "PyAutoMind/bug/health_fixes/.md prompt and run start_dev; leave " + "PyAutoMind/draft/bug/health_fixes/.md prompt and run start_dev; leave " "flaky/expected findings for the Health conductor. Confirm with the vitals " "faculty (never query Heart directly).") @@ -499,7 +498,7 @@ def main(argv=None): or a.ambitious or a.impact) else "selection" ranked, total = select_bug(mind, constraint, a.limit) if not ranked: - print("bug agent: no bug prompts found in PyAutoMind/bug/.", file=sys.stderr) + print("bug agent: no bug prompts found in PyAutoMind/draft/bug/.", file=sys.stderr) return 4 if a.as_json: diff --git a/agents/conductors/bug/bug.sh b/agents/conductors/bug/bug.sh index 294dcd3..8bc13d9 100755 --- a/agents/conductors/bug/bug.sh +++ b/agents/conductors/bug/bug.sh @@ -72,7 +72,7 @@ json_flag=() # --- health-issue mode: the two health inputs -------------------------------- # (1) the live vitals verdict (via the vitals faculty, never Heart directly), and # (2) the durable findings Heart *filed* as GitHub issues. Both feed _bug.py, which -# classifies each and routes real defects to PyAutoMind/bug/health_fixes/. +# classifies each and routes real defects to PyAutoMind/draft/bug/health_fixes/. if [[ "${forward[0]}" == "health" ]]; then # Diagnostics go to stderr so stdout stays pure (valid JSON under --json). echo "== bug agent: health-issue mode ==" >&2 diff --git a/agents/conductors/feature/AGENTS.md b/agents/conductors/feature/AGENTS.md index bfca4b1..9159fb7 100644 --- a/agents/conductors/feature/AGENTS.md +++ b/agents/conductors/feature/AGENTS.md @@ -41,7 +41,7 @@ it reasons over and the PyAutoMemory routing it uses. | Mode | Trigger | What it does | |------|---------|--------------| | **specific** | a task path is given | Read the named prompt, classify repos, consult memory, size it, decide phasing, and produce a `start_dev`-ready plan. | -| **selection** | no task given | Scan `feature/**`, rank candidates, and recommend the best next task — **not** merely the first in a list; down-ranks in-flight work (from `active.md` / `planned.md`). | +| **selection** | no task given | Scan `draft/feature/**` (legacy flat `feature/**` still resolves), rank candidates, and recommend the best next task — **not** merely the first in a list; down-ranks in-flight work (from `active.md` / `planned.md`). | | **difficulty-constrained** | `--difficulty` / `--model` / `--budget` / `--ambitious` / `--impact` | Estimate difficulty per task and select to match the constraint (easy/weak-model/limited-token → small; ambitious/strong-model → large; impact → high-leverage). | ## Difficulty & sizing diff --git a/agents/conductors/feature/_feature.py b/agents/conductors/feature/_feature.py index c94cb5f..d119ee6 100755 --- a/agents/conductors/feature/_feature.py +++ b/agents/conductors/feature/_feature.py @@ -34,7 +34,8 @@ WORK_TYPES, LIBRARY_REPOS, WORKSPACE_REPOS, ORGANISM_REPOS, REPO_ALIASES, KNOWN_REPOS, MEMORY_WIKIS, SCIENCE_KEYWORDS, RISK_KEYWORDS, AMBIGUITY_KEYWORDS, policy as _sizing_policy, - TEST_KEYWORDS, normalise_repo, parse_prompt, estimate_difficulty, _hits, _within, + TEST_KEYWORDS, normalise_repo, parse_prompt, discover_prompts, estimate_difficulty, + _hits, _within, ) # Default sub-wiki to consult per library target when no keyword fires. Memory @@ -183,10 +184,7 @@ def analyse(p: dict): # --- task discovery + selection ---------------------------------------------- def discover(mind: Path): - feat = mind / "feature" - if not feat.is_dir(): - return [] - return sorted(feat.rglob("*.md")) + return discover_prompts(mind, "feature") def _referenced_paths(mind: Path, *names): diff --git a/agents/conductors/refactor/AGENTS.md b/agents/conductors/refactor/AGENTS.md index b900ad2..5ed10c5 100644 --- a/agents/conductors/refactor/AGENTS.md +++ b/agents/conductors/refactor/AGENTS.md @@ -9,7 +9,7 @@ > behaviour-preserving by definition, so tests + the review faculty form a > near-complete gate. -It reasons over `PyAutoMind/refactor/*` intent and produces a structured +It reasons over `PyAutoMind/draft/refactor/*` intent and produces a structured **RefactorDecision** the standard workflow consumes: ``` diff --git a/agents/conductors/refactor/_refactor.py b/agents/conductors/refactor/_refactor.py index c5d21d0..f64c9d5 100755 --- a/agents/conductors/refactor/_refactor.py +++ b/agents/conductors/refactor/_refactor.py @@ -26,7 +26,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "feature")) from _feature import analyse # noqa: E402 (pulls _sizing onto the path too) -from _sizing import parse_prompt, policy as _sizing_policy # noqa: E402 +from _sizing import discover_prompts, parse_prompt, policy as _sizing_policy # noqa: E402 # Public-API-change smells: a "refactor" prompt matching these is suspect — # it belongs in feature/ (or bug/) and must not run at `safe`. @@ -105,8 +105,7 @@ def decide(prompt_path: Path, mind: Path) -> dict: def discover(mind: Path) -> list[Path]: - root = mind / "refactor" - return sorted(root.rglob("*.md")) if root.is_dir() else [] + return discover_prompts(mind, "refactor") def candidates(mind: Path) -> dict: diff --git a/agents/faculties/sizing/_sizing.py b/agents/faculties/sizing/_sizing.py index 1d601d2..a201cb5 100755 --- a/agents/faculties/sizing/_sizing.py +++ b/agents/faculties/sizing/_sizing.py @@ -156,6 +156,41 @@ def _hits(text: str, keywords) -> list: return out +def discover_prompts(mind: Path, work_type: str) -> list[Path]: + """Every backlog prompt of one work-type, across the Mind lifecycle layout. + + The discovery counterpart to `parse_prompt`, and shared for the same reason: + three conductors (feature / bug / refactor) each held a private copy rooted + at the pre-#71 `mind//`, so from the day the split closed + (2026-07-13) all three selection modes silently returned "no prompts found" + against a live backlog. + + Covers the two regimes that hold *backlog* prompts, mirroring `parse_prompt`: + + - `draft///*.md` — the current layout (PyAutoMind#71); + - `//*.md` — legacy flat, pre-migration. + + `active/` is deliberately NOT discovered: it is flat (so its paths carry no + work-type to filter on) and holds issued, in-flight work, whereas selection + answers "what should I start next". `complete/` — records, not backlog — is + excluded by construction, since neither root above can reach into it. + """ + seen, out = set(), [] + for root in (mind / "draft" / work_type, mind / work_type): + if not root.is_dir(): + continue + for p in root.rglob("*.md"): + # READMEs document a folder; they are never themselves tasks. + if p.name.lower() == "readme.md": + continue + key = p.resolve() + if key in seen: + continue + seen.add(key) + out.append(p) + return sorted(out) + + def parse_prompt(path: Path, mind: Path): """Read a prompt file and extract structure: work-type, target, repos, body.""" text = path.read_text(encoding="utf-8", errors="replace") diff --git a/skills/bug/bug.md b/skills/bug/bug.md index 9288208..c8f5fee 100644 --- a/skills/bug/bug.md +++ b/skills/bug/bug.md @@ -13,7 +13,7 @@ Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. `bin/pyauto-brain bug select --difficulty easy | --impact | --model strong`. 2. **From PyAutoHeart** — run `bin/pyauto-brain bug health`: it reads the live vitals verdict **and** scans the filed PyAutoHeart issues, hinting a category per finding; - confirm the real defects and file them under `PyAutoMind/bug/health_fixes/`. + confirm the real defects and file them under `PyAutoMind/draft/bug/health_fixes/`. 3. Take the emitted `BugDecision` (classification, **fix locus**, strategy, workflow) and continue with **`/start_dev`** on the chosen bug — that carries the branch survey, issue creation, and registration. diff --git a/tests/test_conductor_discovery.py b/tests/test_conductor_discovery.py new file mode 100644 index 0000000..0df09aa --- /dev/null +++ b/tests/test_conductor_discovery.py @@ -0,0 +1,131 @@ +"""tests/test_conductor_discovery.py — conductor discovery vs the Mind lifecycle layout. + +The companion to `test_sizing_paths.py`. That file locks how a prompt path is +*read*; this one locks how prompts are *found* — the half the 2026-07-16 sweep +missed, which left `feature` / `bug` / `refactor` selection silently returning +"no prompts found" against a live backlog for as long as the split had been in +place (all three rooted discovery at the pre-#71 `mind//`). + +The end-to-end cases at the bottom are the real regression guard: they assert +each conductor's selection surface is non-empty on a draft/-layout Mind, which +is the exact assertion that would have caught the original bug. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "agents" / "faculties" / "sizing")) +sys.path.insert(0, str(ROOT / "agents" / "conductors" / "feature")) +sys.path.insert(0, str(ROOT / "agents" / "conductors" / "bug")) +sys.path.insert(0, str(ROOT / "agents" / "conductors" / "refactor")) + +from _sizing import discover_prompts # noqa: E402 +import _feature # noqa: E402 +import _bug # noqa: E402 +import _refactor # noqa: E402 + + +def _write(mind: Path, rel: str, work_type: str = "bug", target: str = "PyAutoHands") -> Path: + p = mind / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text( + f"# A task\n\n" + f"Type: {work_type}\n" + f"Target: {target}\n" + f"Repos:\n- {target}\n" + f"Difficulty: small\nAutonomy: safe\nPriority: normal\nStatus: formalised\n\n" + f"Body mentioning @{target}.\n" + ) + return p + + +def _rel(mind: Path, paths) -> set[str]: + return {str(p.relative_to(mind)) for p in paths} + + +# --- discover_prompts: the layout regimes ------------------------------------- +def test_draft_layout_is_discovered(tmp_path): + _write(tmp_path, "draft/bug/pyautohands/one.md") + _write(tmp_path, "draft/bug/pyautofit/two.md") + assert _rel(tmp_path, discover_prompts(tmp_path, "bug")) == { + "draft/bug/pyautohands/one.md", + "draft/bug/pyautofit/two.md", + } + + +def test_legacy_flat_layout_still_resolves(tmp_path): + """Pre-migration paths keep working, mirroring parse_prompt's third regime.""" + _write(tmp_path, "bug/pyautohands/legacy.md") + assert _rel(tmp_path, discover_prompts(tmp_path, "bug")) == {"bug/pyautohands/legacy.md"} + + +def test_both_layouts_coexist_without_duplicates(tmp_path): + _write(tmp_path, "draft/bug/pyautohands/one.md") + _write(tmp_path, "bug/pyautohands/legacy.md") + found = discover_prompts(tmp_path, "bug") + assert _rel(tmp_path, found) == {"draft/bug/pyautohands/one.md", "bug/pyautohands/legacy.md"} + assert len(found) == len(set(found)) + + +def test_work_types_do_not_bleed_into_each_other(tmp_path): + _write(tmp_path, "draft/bug/pyautohands/a_bug.md", work_type="bug") + _write(tmp_path, "draft/feature/pyautohands/a_feature.md", work_type="feature") + assert _rel(tmp_path, discover_prompts(tmp_path, "bug")) == {"draft/bug/pyautohands/a_bug.md"} + assert _rel(tmp_path, discover_prompts(tmp_path, "feature")) == { + "draft/feature/pyautohands/a_feature.md" + } + + +# --- discover_prompts: what must stay out ------------------------------------- +def test_readme_is_not_a_task(tmp_path): + _write(tmp_path, "draft/bug/health_fixes/one.md") + (tmp_path / "draft/bug/health_fixes/README.md").write_text("# documents the folder\n") + assert _rel(tmp_path, discover_prompts(tmp_path, "bug")) == {"draft/bug/health_fixes/one.md"} + + +def test_complete_records_are_not_backlog(tmp_path): + """complete/ holds shipped records; selection must never resurface them.""" + _write(tmp_path, "draft/bug/pyautohands/live.md") + _write(tmp_path, "complete/2026/07/bug/shipped.md") + _write(tmp_path, "complete/archive/shelved/bug/old.md") + assert _rel(tmp_path, discover_prompts(tmp_path, "bug")) == {"draft/bug/pyautohands/live.md"} + + +def test_active_is_not_discovered(tmp_path): + """active/ is issued, in-flight work; selection answers 'what next'.""" + _write(tmp_path, "draft/bug/pyautohands/live.md") + _write(tmp_path, "active/issued.md") + assert _rel(tmp_path, discover_prompts(tmp_path, "bug")) == {"draft/bug/pyautohands/live.md"} + + +def test_missing_roots_are_not_an_error(tmp_path): + assert discover_prompts(tmp_path, "refactor") == [] + + +# --- end-to-end: the assertion that would have caught the original bug -------- +def _populated(tmp_path) -> Path: + _write(tmp_path, "draft/feature/pyautohands/feat.md", work_type="feature") + _write(tmp_path, "draft/bug/pyautohands/bug.md", work_type="bug") + _write(tmp_path, "draft/refactor/pyautohands/ref.md", work_type="refactor") + return tmp_path + + +def test_feature_selection_is_non_empty_on_draft_layout(tmp_path): + ranked, total = _feature.select(_populated(tmp_path), {}, 5) + assert total == 1 + assert [r["path"] for r in ranked] == ["draft/feature/pyautohands/feat.md"] + + +def test_bug_selection_is_non_empty_on_draft_layout(tmp_path): + ranked, total = _bug.select_bug(_populated(tmp_path), {}, 5) + assert total == 1 + assert [r["path"] for r in ranked] == ["draft/bug/pyautohands/bug.md"] + + +def test_refactor_backlog_is_non_empty_on_draft_layout(tmp_path): + assert _refactor.candidates(_populated(tmp_path))["backlog"] == [ + "draft/refactor/pyautohands/ref.md" + ]