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
3 changes: 2 additions & 1 deletion agents/conductors/bug/_bug.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions agents/conductors/feature/_feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions agents/conductors/refactor/_refactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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]
Expand Down
33 changes: 33 additions & 0 deletions agents/faculties/sizing/_sizing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
64 changes: 63 additions & 1 deletion tests/test_conductor_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading