diff --git a/agents/conductors/bug/_bug.py b/agents/conductors/bug/_bug.py index 1301665..d9f860c 100755 --- a/agents/conductors/bug/_bug.py +++ b/agents/conductors/bug/_bug.py @@ -498,7 +498,8 @@ 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/draft/bug/.", file=sys.stderr) + print(f"bug agent: no bug prompts to select from — " + f"{F.empty_discovery_reason(mind, 'bug')}", file=sys.stderr) return 4 if a.as_json: diff --git a/agents/conductors/feature/_feature.py b/agents/conductors/feature/_feature.py index d119ee6..189f57d 100755 --- a/agents/conductors/feature/_feature.py +++ b/agents/conductors/feature/_feature.py @@ -34,8 +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, discover_prompts, estimate_difficulty, - _hits, _within, + TEST_KEYWORDS, normalise_repo, parse_prompt, discover_prompts, + empty_discovery_reason, estimate_difficulty, _hits, _within, ) # Default sub-wiki to consult per library target when no keyword fires. Memory @@ -331,7 +331,8 @@ def main(argv=None): or a.ambitious or a.impact) else "selection" ranked, total = select(mind, constraint, a.limit) if not ranked: - print("feature agent: no feature prompts found in PyAutoMind.", file=sys.stderr) + print(f"feature agent: no feature prompts to select from — " + f"{empty_discovery_reason(mind, 'feature')}", file=sys.stderr) return 4 if a.as_json: diff --git a/agents/conductors/refactor/_refactor.py b/agents/conductors/refactor/_refactor.py index f64c9d5..172264b 100755 --- a/agents/conductors/refactor/_refactor.py +++ b/agents/conductors/refactor/_refactor.py @@ -26,7 +26,9 @@ 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 discover_prompts, parse_prompt, policy as _sizing_policy # noqa: E402 +from _sizing import ( # noqa: E402 + discover_prompts, empty_discovery_reason, parse_prompt, policy as _sizing_policy, +) # Public-API-change smells: a "refactor" prompt matching these is suspect — # it belongs in feature/ (or bug/) and must not run at `safe`. @@ -182,7 +184,8 @@ def main(argv=None) -> int: else: backlog = discover(mind) if not backlog: - print("refactor: no prompts under refactor/ — try 'candidates'", + print(f"refactor: no refactor prompts to select from — " + f"{empty_discovery_reason(mind, 'refactor')} — try 'candidates'", file=sys.stderr) return 4 decisions = [decide(p, mind) for p in backlog] diff --git a/agents/faculties/sizing/_sizing.py b/agents/faculties/sizing/_sizing.py index a201cb5..aa3aece 100755 --- a/agents/faculties/sizing/_sizing.py +++ b/agents/faculties/sizing/_sizing.py @@ -191,6 +191,39 @@ def discover_prompts(mind: Path, work_type: str) -> list[Path]: return sorted(out) +def empty_discovery_reason(mind: Path, work_type: str) -> str: + """Explain an empty `discover_prompts` result — is it a bare backlog or a bad root? + + A flat "no prompts found" reads identically whether the backlog is genuinely + empty or discovery is pointed somewhere wrong. That ambiguity is what let + PyAutoBrain#211 survive four weeks: three conductors reported an empty + backlog while sitting on 87 prompts, and the message a broken root produced + was the message an empty backlog produced. Diagnosing the empty case is + therefore not cosmetic — it is the signal that bug lacked. + + Diagnosis only; callers keep their own exit codes. + """ + if not mind.is_dir(): + return f"PyAutoMind path is not a directory: {mind}" + + draft = mind / "draft" + # A Mind always carries its registry; `draft/` alone can be absent on a + # freshly-spawned one that has taken no work yet. + if not draft.is_dir() and not (mind / "active.md").is_file(): + return (f"{mind} does not look like a PyAutoMind checkout " + f"(no draft/ and no active.md) — check PYAUTO_MIND") + + roots = [r for r in (draft / work_type, mind / work_type) if r.is_dir()] + if not roots: + known = sorted(p.name for p in draft.iterdir() if p.is_dir()) if draft.is_dir() else [] + have = f"; work-types present: {', '.join(known)}" if known else "" + return (f"no '{work_type}' work-type folder under {mind}/draft/ " + f"(nor a legacy flat {work_type}/){have}") + + where = ", ".join(str(r.relative_to(mind)) for r in roots) + return f"{where} exists under {mind} but holds no prompts (backlog genuinely empty)" + + 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/tests/test_conductor_discovery.py b/tests/test_conductor_discovery.py index 0df09aa..a6eba8d 100644 --- a/tests/test_conductor_discovery.py +++ b/tests/test_conductor_discovery.py @@ -22,7 +22,7 @@ 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 +from _sizing import discover_prompts, empty_discovery_reason # noqa: E402 import _feature # noqa: E402 import _bug # noqa: E402 import _refactor # noqa: E402 @@ -129,3 +129,65 @@ def test_refactor_backlog_is_non_empty_on_draft_layout(tmp_path): assert _refactor.candidates(_populated(tmp_path))["backlog"] == [ "draft/refactor/pyautohands/ref.md" ] + + +# --- empty_discovery_reason: an empty result must say WHICH empty it is ------- +# A flat "no prompts found" reads the same whether the backlog is bare or the +# root is wrong. That ambiguity is what let the original bug survive four weeks, +# so the three cases below must stay mutually distinguishable. +def test_reason_flags_a_path_that_is_not_a_directory(tmp_path): + reason = empty_discovery_reason(tmp_path / "nope", "bug") + assert "not a directory" in reason + + +def test_reason_flags_a_directory_that_is_not_a_mind(tmp_path): + (tmp_path / "random").mkdir() + reason = empty_discovery_reason(tmp_path / "random", "bug") + assert "does not look like a PyAutoMind checkout" in reason + assert "PYAUTO_MIND" in reason + + +def test_reason_names_the_missing_work_type_and_lists_those_present(tmp_path): + _write(tmp_path, "draft/bug/pyautohands/one.md") + _write(tmp_path, "draft/feature/pyautohands/two.md", work_type="feature") + (tmp_path / "active.md").write_text("# Active Tasks\n") + reason = empty_discovery_reason(tmp_path, "refactor") + assert "no 'refactor' work-type folder" in reason + assert "work-types present: bug, feature" in reason + + +def test_reason_reports_a_genuinely_empty_backlog(tmp_path): + (tmp_path / "draft" / "bug").mkdir(parents=True) + (tmp_path / "active.md").write_text("# Active Tasks\n") + reason = empty_discovery_reason(tmp_path, "bug") + assert "holds no prompts" in reason + assert "backlog genuinely empty" in reason + + +def test_the_three_empty_causes_are_mutually_distinguishable(tmp_path): + not_a_mind = tmp_path / "random" + not_a_mind.mkdir() + + missing_type = tmp_path / "mind_a" + _write(missing_type, "draft/bug/pyautohands/one.md") + (missing_type / "active.md").write_text("# Active Tasks\n") + + bare = tmp_path / "mind_b" + (bare / "draft" / "bug").mkdir(parents=True) + (bare / "active.md").write_text("# Active Tasks\n") + + reasons = { + empty_discovery_reason(not_a_mind, "bug"), + empty_discovery_reason(missing_type, "refactor"), + empty_discovery_reason(bare, "bug"), + } + assert len(reasons) == 3 + + +def test_a_freshly_spawned_mind_without_draft_is_still_a_mind(tmp_path): + """A Mind that has taken no work yet has active.md but no draft/ — that is + an empty backlog, not a bad path.""" + (tmp_path / "active.md").write_text("# Active Tasks\n") + reason = empty_discovery_reason(tmp_path, "bug") + assert "does not look like a PyAutoMind checkout" not in reason + assert "no 'bug' work-type folder" in reason