diff --git a/AGENTS.md b/AGENTS.md index 2e7266d..4166e9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,13 @@ Each agent is a directory under `agents//` with: Current agents: +- **`agents/feature/`** — the **growth function**: reasons over PyAutoMind + `feature/*` intent and decides *how the organism should grow*. Selects the next + feature task (or plans a named one), estimates difficulty, decides whether to + phase, consults PyAutoMemory for scientific/architectural context and (for + risky work) the Health Agent, and emits a `FeatureDecision` that the existing + `start_dev → ship_library/ship_workspace` workflow consumes. It reasons; it + never edits source. (Organism-facing name: *Growth Agent*.) - **`agents/build/`** — the executive function for execution work. Consults the Health Agent, reasons over the verdict, and on a healthy result delegates to the appropriate PyAutoBuild capability. The canonical example of the Brain @@ -117,16 +124,20 @@ Current agents: > consulting the Health Agent *more strictly*, then requesting execution from the > Build Agent / PyAutoBuild. Until then: one agent now, clean seam for two later. -More specialist agents are expected over time (e.g. a Feature agent that reasons -over PyAutoMind tasks, Bug / Refactor / Documentation / Research agents, and a -split-out Release agent). The Build Agent is the reusable template — add new -ones as `agents//` directories following its shape (a concise `AGENTS.md`, -a deterministic entrypoint, and a capability audit of any organ it drives). +More specialist agents are expected over time (Bug / Refactor / Documentation / +Research agents, and a split-out Release agent); the Feature Agent above is the +first of these, the Brain agent that reasons over PyAutoMind `feature/*` intent. +The Build Agent is the reusable template — add new ones as `agents//` +directories following its shape (a concise `AGENTS.md`, a deterministic +entrypoint, and a capability audit of any organ it drives — the Feature Agent's +`MIND_TAXONOMY.md` is that audit for the PyAutoMind/PyAutoMemory surface). ## Running ```bash bin/pyauto-brain help # list agents +bin/pyauto-brain feature # select the best next PyAutoMind feature task +bin/pyauto-brain feature feature/autofit/sbi.md # plan a specific feature task bin/pyauto-brain build # consult health, then delegate execution to Build bin/pyauto-brain build --dry-run # reason + plan only (emit the BuildDecision) bin/pyauto-brain release # reason about readiness, then release on green diff --git a/agents/_common.sh b/agents/_common.sh index 15facdd..2ed2397 100755 --- a/agents/_common.sh +++ b/agents/_common.sh @@ -35,6 +35,35 @@ resolve_autobuild() { _resolve_bin autobuild "$PYAUTO_ROOT/PyAutoBuild/bin/autobuild" } +# _resolve_dir — echo the path to a sibling PyAuto +# *repository checkout* (not a binary), or print a hint to stderr and return 1. +# Organs like PyAutoMind and PyAutoMemory are markdown knowledge bases with no +# CLI: the Brain reasons over their files directly. Resolution order: an explicit +# env override (e.g. PYAUTO_MIND), then $PYAUTO_ROOT/, then a couple of +# common dev layouts ($HOME/, $HOME/Code/). +_resolve_dir() { + local var="$1" repo="$2" override="${!1:-}" c + if [[ -n "$override" && -d "$override" ]]; then printf '%s' "$override"; return 0; fi + # The parent of this PyAutoBrain checkout is the most reliable sibling root + # (organs are typically cloned side by side), so check it first. + local brain_parent + brain_parent="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../.." && pwd)" + for c in "$brain_parent/$repo" "$PYAUTO_ROOT/$repo" "$HOME/$repo" \ + "$HOME/Code/$repo" "$HOME/Code/PyAutoLabs/$repo"; do + if [[ -d "$c" ]]; then printf '%s' "$c"; return 0; fi + done + echo "pyauto-brain: '$repo' checkout not found (set $var, or clone it beside PyAutoBrain / under $PYAUTO_ROOT)" >&2 + return 1 +} + +# resolve_mind — locate the PyAutoMind checkout (the organism's intent store). +resolve_mind() { _resolve_dir PYAUTO_MIND PyAutoMind; } + +# resolve_memory — locate the PyAutoMemory checkout (the organism's long-term +# scientific/architectural knowledge). Optional: the Feature Agent degrades +# gracefully when memory is absent rather than inventing context. +resolve_memory() { _resolve_dir PYAUTO_MEMORY PyAutoMemory; } + # readiness_verdict — run `pyauto-heart readiness --json` and echo the verdict # string (green/yellow/red). Returns non-zero if Heart can't be resolved/run. readiness_verdict() { diff --git a/agents/feature/AGENTS.md b/agents/feature/AGENTS.md new file mode 100644 index 0000000..94388b1 --- /dev/null +++ b/agents/feature/AGENTS.md @@ -0,0 +1,116 @@ +# Feature agent + +The **growth function** of PyAutoBrain. It reasons over the feature *intent* +stored in PyAutoMind and decides **how the organism should grow** — which feature +task to work on, how hard it is, whether it must be phased, what scientific +context applies, and which development path to take. It does **not** implement +code: it produces a structured `FeatureDecision` that the existing development +workflow consumes. + +``` +Mind (PyAutoMind feature/*) → Feature Agent → start_dev + → start_library / ship_library + → start_workspace / ship_workspace + consults ↘ ↙ consults + Health Agent PyAutoMemory (scientific / architectural context) +``` + +> Long term this is the organism-facing **Growth Agent**; *Feature Agent* is the +> engineering-facing name and the safe first implementation. + +## Fundamental principle + +**The Feature Agent reasons; it does not build.** It never edits source. It reads +intent, consults memory, estimates difficulty, decides phasing, and emits a plan +that `start_dev` / `ship_*` execute. Implementation only happens when the plan is +handed to the existing workflow. + +## Brain agents consult one another + +Like the Build Agent, the Feature Agent is a citizen of the society of agents. +For risky / multi-repo / release-bound work it **consults the sibling Health +Agent** (`--check-health`) rather than querying the Heart organ directly — only +the Health Agent talks to Heart. It also consults **PyAutoMemory** for scientific +and architectural context, and **never invents science** when memory has +material. See [`MIND_TAXONOMY.md`](./MIND_TAXONOMY.md) for the PyAutoMind taxonomy +it reasons over and the PyAutoMemory routing it uses. + +## Three modes + +| 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`). | +| **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 + +Difficulty is a transparent heuristic (`small | medium | large | too-large`) over +repos affected, prompt size, scientific complexity, architectural risk, test +burden, and whether memory context / human judgement is required. The factor +breakdown is in every decision so the reasoning layer can adjust. + +Sizing then drives the **phase decision**: + +- **direct** — small/medium; one PR. +- **split-into-phases** — large/too-large; prefer several small shippable PRs over + one fragile PR. For *too-large* it emits phase stubs, e.g. + `feature//_phase_1_design.md … _phase_4_docs.md`. +- **research-first** — ambiguous, no repo resolved; open a `research/` task first. +- **defer / re-home** — if the prompt is mis-filed (a bug, refactor, research or + experiment), it says so and suggests the correct PyAutoMind category. + +## Run + +```bash +bin/pyauto-brain feature # selection mode +bin/pyauto-brain feature feature/autofit/sbi.md # specific mode +bin/pyauto-brain feature select --difficulty easy # easy task +bin/pyauto-brain feature select --model strong --limit 5 # ambitious shortlist +bin/pyauto-brain feature select --impact # highest-leverage +bin/pyauto-brain feature --check-health feature/autolens/x.md # also consult Health +bin/pyauto-brain feature --json select # machine-readable +``` + +A bare path is treated as `specific`; nothing given is `selection`. `--json` +emits the full `FeatureDecision` (with a `shortlist` in selection modes). + +Exit codes: `0` produced a decision · `4` no prompts / could-not-resolve mind · +`5` bad usage (unknown task / flag). + +## FeatureDecision (the structured return) + +Mirrors the spec's required fields: + +``` +Selected task · Mode · Work-type/target · Repos affected · Difficulty (+score) +Recommended workflow (library|workspace|combined|research|experiment|refactor|bug) +Relevant context (PyAutoMemory sub-wikis to consult) · Phase decision (+stubs) +Execution plan (start_dev / start_library / ship_library / start_workspace / …) +Health considerations · Risks · Next action (one concrete step) +``` + +`--json` returns the same shape for programmatic use (a future Python +`FeatureAgent().decide(...)` can return it verbatim). + +## Workflow mapping + +- **library** → `start_dev` → `start_library` → `ship_library` +- **workspace** → `start_dev` → `start_workspace` → `ship_workspace` +- **combined** → library PR first (so the workspace consumes its `## API Changes` + summary), then the workspace PR; ship both in order. + +Library vs. workspace is decided from the `@RepoName` references in the prompt +body, not the folder (per PyAutoMind `ROUTING.md`). + +## What this agent must never do + +- Edit source, open PRs, or run builds itself — that is the Build Agent / + PyAutoBuild via `start_dev` / `ship_*`. +- Query PyAutoHeart directly — consult the Health Agent (`--check-health`). +- Invent scientific or architectural context when PyAutoMemory has material — + cite the sub-wiki instead. +- Just pick the first prompt in selection mode — rank, and explain the choice. + +See [`MIND_TAXONOMY.md`](./MIND_TAXONOMY.md) for the PyAutoMind work-type taxonomy, +the PyAutoMemory sub-wiki routing, and the difficulty heuristic in detail. diff --git a/agents/feature/MIND_TAXONOMY.md b/agents/feature/MIND_TAXONOMY.md new file mode 100644 index 0000000..466e714 --- /dev/null +++ b/agents/feature/MIND_TAXONOMY.md @@ -0,0 +1,116 @@ +# PyAutoMind & PyAutoMemory surface known to the Feature Agent + +This audit records the **intent surface** (PyAutoMind) the Feature Agent reasons +over and the **knowledge surface** (PyAutoMemory) it consults. Both are markdown +knowledge bases with no CLI — the Brain reasons over their files directly. The +agent must treat these as inputs to reason about, never as logic to reimplement. +Sources of truth: `PyAutoMind/ROUTING.md`, `PyAutoMind/README.md`, +`PyAutoMemory/index.md`. + +## PyAutoMind taxonomy (intent → work-type) + +Prompts live at `//.md`. The **work-type** (first +folder) declares the kind of thinking required; the **target** (second folder) +names the affected repo or domain. The Feature Agent owns `feature/` and helps +keep it organised — re-homing mis-filed prompts. + +| Work-type | Intent | If a `feature/` prompt is really this → re-home as | +|-----------|--------|-----------------------------------------------------| +| `feature/` | new user-facing / scientific capability | (stays) | +| `bug/` | incorrect behaviour, crash, regression | `bug/` | +| `refactor/` | internal restructuring, no behaviour change | `refactor/` | +| `research/` | unclear science → investigate before building | `research/` | +| `experiment/` | proof-of-concept / spike | `experiment/` | +| `docs/` `test/` `release/` `maintenance/` `triage/` | docs / tests / release / hygiene / unclear | matching folder | + +The agent classifies a prompt's intent and, when it does not match `feature/`, +states the better category in `rehome_suggestion` rather than planning code. + +## Targets → library vs. workspace + +The `/start_library` ↔ `/start_workspace` split is decided from the `@RepoName` +references in the **prompt body**, not the folder (per `ROUTING.md`). + +- **Libraries** (source): PyAutoConf, PyAutoFit, PyAutoArray, PyAutoGalaxy, + PyAutoLens (`autoconf`, `autofit`, `autoarray`, `autogalaxy`, `autolens`; + aliases `aa`/`af`/`ag`/`al`). API paths like `@aa.decorators.transform` + resolve by their head token (`aa` → `autoarray`). +- **Workspaces / tutorials / examples**: `autolens_workspace`(`_test`), + `autogalaxy_workspace`(`_test`), `autofit_workspace`(`_test`), `HowToLens`, + `HowToGalaxy`, `HowToFit`, `autolens_assistant`, `autolens_profiling`, and the + `workspaces` bucket. + +`@`-mentions that resolve to neither (e.g. `@z_projects`, `@jax`) are dropped so +the repo count reflects real affected repos. Workflow mapping: + +- library only → `start_dev → start_library → ship_library` +- workspace only → `start_dev → start_workspace → ship_workspace` +- both → library PR first (workspace consumes its `## API Changes` summary), then + the workspace PR; ship in order. + +## Workflow state the agent reads + +- `active.md` — in-flight tasks (sessions, worktrees, claimed repos). +- `planned.md` — filed-but-not-started tasks. +- `queue.md` — ordered processing queues. + +In **selection** mode the agent extracts `feature/...md` paths referenced in +`active.md` / `planned.md` and **down-ranks** them, so it surfaces genuinely new +next work rather than resurfacing what is already moving. Priorities and +inter-task dependencies are applied by the reasoning layer on top of the ranking. + +## PyAutoMemory routing (scientific / architectural context) + +Before planning substantial scientific or architectural work, the agent maps the +task to the relevant PyAutoMemory sub-wiki and **cites it** — it does not invent +context when memory has material. Sub-wikis (source: `PyAutoMemory/index.md`): + +| Sub-wiki | Domain | Triggered by (examples) | +|----------|--------|--------------------------| +| `lensing_wiki/` | strong gravitational lensing | lens, deflection, source reconstruction, subhalo, time delay, cosmography, SLACS/TDCOSMO | +| `smbh_wiki/` | supermassive black holes | black hole, SMBH, binary, recoil, NANOGrav | +| `cti_wiki/` | charge-transfer inefficiency | CTI, trap, arctic, VIS calibration | +| `methods_wiki/` | statistical / computational methods | Bayesian, sampler, JAX, NUFFT, SBI, graphical models, deep learning | +| `galaxies_wiki/` | galaxy formation & evolution | bulge/disk, MGE, morphology, IFU, kinematics | + +Library targets also pull a default sub-wiki (`autolens`→lensing, +`autogalaxy`→galaxies, `autofit`/`autoarray`/`autoconf`→methods). PyAutoMemory is +**optional**: if the checkout is absent the agent still names the sub-wikis to +read, degrading gracefully rather than failing. + +## Difficulty heuristic (transparent by design) + +`_feature.py` scores each task and the score appears in every decision: + +| Signal | Contribution | +|--------|--------------| +| repos affected | `(count − 1) × 2` — the dominant driver | +| library **and** workspace | `+2` (coordination cost) | +| prompt size | `+min(words/150, 4)` | +| scientific complexity | `+min(#keywords, 3)` | +| architectural / API risk | `+min(#keywords × 2, 4)` | +| test burden (JAX, smoke, parity, …) | `+1` | +| memory context required | `+1` | + +Thresholds: `≤2 small · ≤5 medium · ≤9 large · >9 too-large`. These are a +**v1 heuristic**: the factor breakdown is exposed precisely so the reasoning +layer can override the bucket (e.g. a pre-phased prompt that scores "too-large" +is already a single phase). When the keyword lists or thresholds drift, update +them here and in `_feature.py` together; do not encode them anywhere the agent +must re-derive at runtime. + +## Boundary audit — reasoning vs. intent vs. knowledge vs. execution + +``` +intent → PyAutoMind (feature/* prompts, active/planned/queue state) +reasoning → PyAutoBrain (Feature Agent — this) +knowledge → PyAutoMemory (via direct file reads; cited, never invented) +health → PyAutoHeart (via the Health Agent, never queried directly) +execution → PyAutoBuild (via start_dev / ship_* — never run by this agent) +``` + +No execution, health-checking, or knowledge-authoring logic lives in the Feature +Agent. It reads intent, consults knowledge and (optionally) health, reasons, and +hands a plan to the existing workflow. If intent-shaping logic ever creeps in +here, it belongs back in PyAutoMind; if knowledge authoring creeps in, it belongs +in PyAutoMemory. diff --git a/agents/feature/_feature.py b/agents/feature/_feature.py new file mode 100755 index 0000000..39974dd --- /dev/null +++ b/agents/feature/_feature.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +"""agents/feature/_feature.py — analysis core for the PyAutoBrain Feature Agent. + +The Feature Agent is the "growth function" of PyAutoBrain: it reasons over the +feature intent stored in PyAutoMind and decides *how the organism should grow*. +It does NOT implement code — it produces a structured FeatureDecision that the +existing development workflow (start_dev -> start_library/start_workspace -> +ship_library/ship_workspace) can consume. + +This module is the deterministic part: it discovers feature prompts, classifies +their work-type / target repos, estimates difficulty, decides phasing, and maps +the task to relevant PyAutoMemory sub-wikis. The richer judgement (priorities, +dependencies, health) is documented in AGENTS.md and applied by the reasoning +layer on top of this scaffold. feature.sh is the entrypoint that resolves the +PyAutoMind / PyAutoMemory checkouts and calls into here. + +It is intentionally dependency-free (stdlib only) and never writes anything. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +# --- the PyAutoMind taxonomy (mirrors PyAutoMind/ROUTING.md) ----------------- +# work-type folder -> the kind of work and the recommended re-home when a +# feature prompt is mis-filed. The Feature Agent helps keep PyAutoMind organised. +WORK_TYPES = { + "feature": "new user-facing or scientific capability", + "bug": "incorrect behaviour, crash or regression", + "refactor": "internal restructuring, no behaviour change", + "docs": "documentation, tutorials, notebooks, examples", + "test": "test coverage, smoke tests, validation", + "release": "packaging, versions, deployment, readiness", + "maintenance": "dependency updates, hygiene, small tech debt", + "research": "exploratory scientific/algorithmic investigation", + "experiment": "prototype, spike, proof-of-concept", + "triage": "classification still unclear", +} + +# Targets that are source *libraries* (work classifies as library vs workspace). +LIBRARY_REPOS = { + "pyautoconf", "pyautofit", "pyautoarray", "pyautogalaxy", "pyautolens", + "autoconf", "autofit", "autoarray", "autogalaxy", "autolens", +} +# Targets / @-mentions that are workspaces, tutorials or example repos. +WORKSPACE_REPOS = { + "autolens_workspace", "autogalaxy_workspace", "autofit_workspace", + "autolens_workspace_test", "autogalaxy_workspace_test", "autofit_workspace_test", + "howtolens", "howtogalaxy", "howtofit", "autolens_assistant", + "autolens_profiling", "workspaces", +} +# Normalise an @-mention or folder name to a canonical key. +REPO_ALIASES = { + "aa": "autoarray", "af": "autofit", "ag": "autogalaxy", "al": "autolens", + "pyautoarray": "autoarray", "pyautofit": "autofit", "pyautoconf": "autoconf", + "pyautogalaxy": "autogalaxy", "pyautolens": "autolens", +} + +# --- PyAutoMemory sub-wiki routing ------------------------------------------- +# Map target/keywords -> the PyAutoMemory sub-wiki that holds relevant context. +# Source of truth for the sub-wiki list: PyAutoMemory/index.md. +MEMORY_WIKIS = { + "lensing_wiki": ["lens", "deflection", "source reconstruction", "caustic", + "einstein", "subhalo", "substructure", "time delay", + "cosmography", "shear", "multipole", "mass sheet", "slacs", + "tdcosmo", "h0licow", "macromodel"], + "smbh_wiki": ["black hole", "smbh", "binary", "recoil", "nanograv", + "gravitational wave background"], + "cti_wiki": ["charge transfer", "cti", "trap", "arctic", "vis calibration"], + "methods_wiki": ["bayesian", "sampler", "nautilus", "dynesty", "emcee", + "mcmc", "nested sampling", "likelihood", "jax", "nufft", + "interpolat", "graphical model", "expectation propagation", + "deep learning", "sbi", "simulation based inference", + "probabilistic", "optimis", "gradient", "regularis"], + "galaxies_wiki": ["galaxy formation", "bulge", "disk", "morphology", "mge", + "stellar halo", "ifu", "kinematic", "elliptical", "cosmos"], +} +# Default sub-wiki to consult per library target when no keyword fires. +TARGET_DEFAULT_WIKI = { + "autolens": "lensing_wiki", "autogalaxy": "galaxies_wiki", + "autofit": "methods_wiki", "autoarray": "methods_wiki", + "autoconf": "methods_wiki", +} + +SCIENCE_KEYWORDS = sorted({kw for kws in MEMORY_WIKIS.values() for kw in kws}) +RISK_KEYWORDS = ["api", "breaking", "backwards", "migrat", "deprecat", + "cross-repo", "interface", "refactor", "rename", "public api"] +AMBIGUITY_KEYWORDS = ["unclear", "investigate", "explore", "research", "decide", + "figure out", "not sure", "tbd", "open question", "design", + "proof of concept", "prototype", "spike", "?"] +TEST_KEYWORDS = ["test", "smoke", "parity", "jax", "likelihood", "vmap", + "validation", "regression"] + + +def normalise_repo(name: str) -> str: + # Take the head token before any '.' or '/': an @-mention may be an API path + # (e.g. @aa.decorators.to_vector_yx -> aa) or a repo path, not just a name. + key = re.split(r"[./]", name.strip().lstrip("@").lower(), 1)[0] + return REPO_ALIASES.get(key, key) + + +KNOWN_REPOS = LIBRARY_REPOS | WORKSPACE_REPOS + + +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") + try: + rel = path.relative_to(mind) + parts = rel.parts + except ValueError: + parts = path.parts + work_type = parts[0] if parts else "?" + target = parts[1] if len(parts) > 1 else "?" + + mentions = {normalise_repo(m) for m in re.findall(r"@[A-Za-z0-9._/-]+", text)} + # Keep only mentions that resolve to a repo we know — drops project refs + # (@z_projects), bare libraries (@jax) and noise, so the repo count is real. + repos = {m for m in mentions if m in KNOWN_REPOS} + if target not in ("?", "workspaces") and target not in WORK_TYPES: + t = normalise_repo(target) + if t in KNOWN_REPOS: + repos.add(t) + + return { + "path": str(path.relative_to(mind)) if _within(path, mind) else str(path), + "work_type": work_type, + "target": target, + "repos": sorted(repos), + "text": text, + "lines": text.count("\n") + 1, + "words": len(text.split()), + } + + +def _within(path: Path, base: Path) -> bool: + try: + path.relative_to(base) + return True + except ValueError: + return False + + +def _hits(text: str, keywords) -> list: + """Keyword hits using word-boundary *prefix* matching. + + A leading \\b stops short tokens ("cti", "api", "mge") matching inside other + words ("function", "rapid"), while leaving the end open so stems still fire + ("interpolat" -> "interpolation", "migrat" -> "migration"). + """ + low = text.lower() + out = [] + for k in keywords: + if re.search(r"\b" + re.escape(k), low): + out.append(k) + return out + + +def estimate_difficulty(p: dict): + """Heuristic difficulty estimate -> (level, score, factors). + + Considers: repos affected, prompt size, scientific complexity, architectural + risk, test burden, and whether human judgement / memory context is needed. + """ + text = p["text"] + lib = [r for r in p["repos"] if r in LIBRARY_REPOS] + wsp = [r for r in p["repos"] if r in WORKSPACE_REPOS] + repo_count = len(set(p["repos"])) + science = _hits(text, SCIENCE_KEYWORDS) + risk = _hits(text, RISK_KEYWORDS) + tests = _hits(text, TEST_KEYWORDS) + ambiguity = _hits(text, AMBIGUITY_KEYWORDS) + + score = 0 + score += max(0, repo_count - 1) * 2 # multi-repo is the big driver + score += 2 if (lib and wsp) else 0 # library+workspace coordination + score += min(p["words"] // 150, 4) # size of the description + score += min(len(science), 3) # scientific complexity + score += min(len(risk) * 2, 4) # architectural risk + score += 1 if tests else 0 # test burden + score += 1 if science else 0 # memory context likely needed + + if score <= 2: + level = "small" + elif score <= 5: + level = "medium" + elif score <= 9: + level = "large" + else: + level = "too-large" + + factors = { + "repos_affected": repo_count, + "library_repos": lib, + "workspace_repos": wsp, + "library_and_workspace": bool(lib and wsp), + "size_words": p["words"], + "scientific_complexity": science, + "architectural_risk": risk, + "test_burden": tests, + "human_judgement": ambiguity, + "memory_context_required": bool(science), + } + return level, score, factors + + +def recommend_workflow(p: dict, factors: dict): + """Map the task to a development path / re-home suggestion.""" + wt = p["work_type"] + if wt in ("research", "experiment", "bug", "refactor") and wt != "feature": + # Already correctly homed in a non-feature category. + return wt, None + + lib = factors["library_repos"] + wsp = factors["workspace_repos"] + + # Re-home suggestions for mis-filed feature prompts. + rehome = None + if factors["human_judgement"] and not (lib or wsp): + rehome = "research" + if lib and wsp: + return "combined", rehome + if lib: + return "library", rehome + if wsp: + return "workspace", rehome + # No repo resolved — most likely needs scoping first. + return "research", (rehome or "research") + + +def memory_context(p: dict): + """Return the PyAutoMemory sub-wikis worth consulting for this task.""" + text = p["text"] + hits = {} + for wiki, kws in MEMORY_WIKIS.items(): + matched = _hits(text, kws) + if matched: + hits[wiki] = matched + for r in p["repos"]: + d = TARGET_DEFAULT_WIKI.get(r) + if d and d not in hits: + hits.setdefault(d, []).append(f"(default for {r})") + return hits + + +def phase_decision(level: str, factors: dict, p: dict): + """direct | split-into-phases | research-first | defer, plus phase stubs.""" + if factors["human_judgement"] and not (factors["library_repos"] or factors["workspace_repos"]): + return "research-first", [] + if level == "too-large": + # Phase stubs live in the prompt's own target folder (mirrors the + # feature/autofit/sbi_phase_1_design.md example in the spec). + target = p["target"] if p["target"] not in ("?",) else (p["repos"] or ["misc"])[0] + stem = Path(p["path"]).stem + stubs = [ + f"feature/{target}/{stem}_phase_1_design.md", + f"feature/{target}/{stem}_phase_2_core_api.md", + f"feature/{target}/{stem}_phase_3_workspace_examples.md", + f"feature/{target}/{stem}_phase_4_docs.md", + ] + return "split-into-phases", stubs + if level == "large": + return "split-into-phases", [] + return "direct", [] + + +def execution_plan(workflow: str, factors: dict): + """Steps compatible with the existing start_dev / ship_* workflow.""" + if workflow == "library": + return ["start_dev ", "start_library", "ship_library"] + if workflow == "workspace": + return ["start_dev ", "start_workspace", "ship_workspace"] + if workflow == "combined": + return ["start_dev ", "start_library", "ship_library", + "start_workspace (uses the library PR's API-change summary)", + "ship_workspace"] + if workflow in ("research", "experiment"): + return [f"re-home as a {workflow}/ task in PyAutoMind, then scope before start_dev"] + return [f"re-home as a {workflow}/ task, then run start_dev once scoped"] + + +def health_consideration(level: str, factors: dict, workflow: str): + reasons = [] + if factors["repos_affected"] > 1: + reasons.append("affects multiple repositories") + if factors["architectural_risk"]: + reasons.append("carries architectural / API risk") + if level in ("large", "too-large"): + reasons.append("is large") + if workflow == "combined": + reasons.append("requires coordinated library + workspace PRs") + if not reasons: + return "Optional: tree is likely fit; consult the Health Agent if recent CI is unknown." + return ("Consult the Health Agent (pyauto-brain health) before starting — this task " + + ", ".join(reasons) + ".") + + +def risks(level: str, factors: dict, workflow: str): + out = [] + if factors["library_and_workspace"]: + out.append("Library/workspace coordination: ship the library PR first so the " + "workspace can consume its API-change summary.") + if factors["architectural_risk"]: + out.append("Public-API change may ripple to downstream repos.") + if level == "too-large": + out.append("Too large for one PR — a single fragile PR risks review/merge stalls.") + if factors["human_judgement"]: + out.append("Scientific/architectural ambiguity — needs scoping before code.") + if not out: + out.append("Low risk; standard review applies.") + return out + + +def analyse(p: dict): + level, score, factors = estimate_difficulty(p) + workflow, rehome = recommend_workflow(p, factors) + mem = memory_context(p) + phase, stubs = phase_decision(level, factors, p) + return { + "selected_task": p["path"], + "work_type": p["work_type"], + "target": p["target"], + "repos_affected": p["repos"], + "difficulty": level, + "difficulty_score": score, + "difficulty_factors": factors, + "recommended_workflow": workflow, + "rehome_suggestion": rehome, + "memory_context": mem, + "phase_decision": phase, + "phase_stubs": stubs, + "execution_plan": execution_plan(workflow, factors), + "health_considerations": health_consideration(level, factors, workflow), + "risks": risks(level, factors, workflow), + } + + +# --- task discovery + selection ---------------------------------------------- +def discover(mind: Path): + feat = mind / "feature" + if not feat.is_dir(): + return [] + return sorted(feat.rglob("*.md")) + + +def _referenced_paths(mind: Path, *names): + """Prompt paths mentioned in active.md / planned.md (recent / in-flight work).""" + refs = set() + for n in names: + f = mind / n + if f.is_file(): + for m in re.findall(r"[\w./-]*feature/[\w./-]+\.md", f.read_text(errors="replace")): + refs.add(m.split("PyAutoMind/")[-1].lstrip("/")) + return refs + + +DIFF_ORDER = {"small": 0, "medium": 1, "large": 2, "too-large": 3} + + +def select(mind: Path, constraint: dict, limit: int): + prompts = discover(mind) + in_flight = _referenced_paths(mind, "active.md", "planned.md") + rows = [] + for path in prompts: + p = parse_prompt(path, mind) + level, score, factors = estimate_difficulty(p) + impact = score + (2 if factors["library_and_workspace"] else 0) \ + + len(factors["scientific_complexity"]) + rows.append({ + "path": p["path"], "difficulty": level, "score": score, + "impact": impact, "repos": p["repos"], + "in_flight": p["path"] in in_flight, + "factors": factors, + }) + + # Constraint-driven ranking. The script *recommends*; priorities/health/ + # dependencies are layered on by the reasoning agent (see AGENTS.md). + want = constraint.get("difficulty") + model = constraint.get("model") + budget = constraint.get("budget") + impact_pref = constraint.get("impact") + + def keyfn(r): + # Down-rank in-flight work so we never just resurface active tasks. + penalty = 100 if r["in_flight"] else 0 + if impact_pref: + return (penalty, -r["impact"]) + if model == "strong" or constraint.get("ambitious"): + return (penalty, -r["score"]) + if model == "weak" or budget or want in ("easy", "small"): + return (penalty, r["score"]) + return (penalty, r["score"]) # default: easiest-first, stable + + candidates = rows + if want and want not in ("easy",): + candidates = [r for r in rows if r["difficulty"] == want] or rows + elif want == "easy": + candidates = [r for r in rows if r["difficulty"] in ("small", "medium")] or rows + if model == "weak" or budget: + candidates = [r for r in candidates if r["difficulty"] in ("small", "medium")] or candidates + + candidates = sorted(candidates, key=keyfn) + return candidates[:limit], len(prompts) + + +# --- emit --------------------------------------------------------------------- +def emit_human(mode: str, decision: dict): + d = decision + print("== FeatureDecision ==") + print(f"Selected task: {d['selected_task']}") + print(f"Mode: {mode}") + print(f"Work-type / target: {d['work_type']} / {d['target']}") + print(f"Repos affected: {', '.join(d['repos_affected']) or '(none resolved)'}") + print(f"Difficulty: {d['difficulty']} (score {d['difficulty_score']})") + print(f"Recommended workflow: {d['recommended_workflow']}", end="") + print(f" [re-home as {d['rehome_suggestion']}/]" if d["rehome_suggestion"] else "") + if d["memory_context"]: + print("Relevant context (PyAutoMemory — consult, do not invent):") + for wiki, kws in d["memory_context"].items(): + print(f" - {wiki}/index.md ({', '.join(kws)})") + else: + print("Relevant context: none matched (no scientific context required)") + print(f"Phase decision: {d['phase_decision']}") + for s in d["phase_stubs"]: + print(f" - {s}") + print("Execution plan:") + for step in d["execution_plan"]: + print(f" - {step}") + print(f"Health considerations:{chr(10)} {d['health_considerations']}") + print("Risks:") + for r in d["risks"]: + print(f" - {r}") + nxt = _next_action(d) + print(f"Next action: {nxt}") + + +def _next_action(d: dict): + if d["rehome_suggestion"]: + return f"Re-home this prompt under {d['rehome_suggestion']}/ and scope it before development." + if d["phase_decision"] == "split-into-phases": + return "Write the phased feature prompts, then run start_dev on phase 1." + if d["phase_decision"] == "research-first": + return "Open a research/ task to resolve the open questions before implementation." + return f"Run start_dev on {d['selected_task']} (workflow: {d['recommended_workflow']})." + + +def main(argv=None): + ap = argparse.ArgumentParser(prog="feature", add_help=True) + ap.add_argument("--mind", required=True) + ap.add_argument("--memory", default="") + ap.add_argument("--json", action="store_true", dest="as_json") + sub = ap.add_subparsers(dest="cmd", required=True) + + sp = sub.add_parser("specific") + sp.add_argument("task") + + se = sub.add_parser("select") + se.add_argument("--difficulty", default="") + se.add_argument("--model", default="") + se.add_argument("--budget", action="store_true") + se.add_argument("--ambitious", action="store_true") + se.add_argument("--impact", action="store_true") + se.add_argument("--limit", type=int, default=5) + + a = ap.parse_args(argv) + mind = Path(a.mind) + + if a.cmd == "specific": + task = Path(a.task) + if not task.is_absolute(): + task = (mind / a.task) + if not task.is_file(): + print(f"feature agent: task not found: {task}", file=sys.stderr) + return 5 + decision = analyse(parse_prompt(task, mind)) + decision["mode"] = "specific" + if a.as_json: + print(json.dumps({**decision, "next_action": _next_action(decision)}, indent=2)) + else: + emit_human("specific", decision) + return 0 + + # select / difficulty-constrained + constraint = {"difficulty": a.difficulty, "model": a.model, + "budget": a.budget, "ambitious": a.ambitious, "impact": a.impact} + mode = "difficulty-constrained" if (a.difficulty or a.model or a.budget + 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) + return 4 + + if a.as_json: + top = parse_prompt(mind / ranked[0]["path"], mind) + decision = analyse(top) + decision["mode"] = mode + decision["shortlist"] = ranked + decision["candidates_considered"] = total + print(json.dumps({**decision, "next_action": _next_action(decision)}, indent=2)) + return 0 + + print(f"== Feature task {mode} ({total} feature prompts considered) ==") + print("Shortlist (recommendation — apply priorities/dependencies/health on top):") + for i, r in enumerate(ranked): + flag = " [in-flight, down-ranked]" if r["in_flight"] else "" + print(f" {i+1}. {r['path']} [{r['difficulty']}, score {r['score']}, " + f"impact {r['impact']}]{flag}") + print() + chosen = parse_prompt(mind / ranked[0]["path"], mind) + decision = analyse(chosen) + print("Recommended pick (not merely the first prompt — ranked by the constraint):") + emit_human(mode, decision) + print("\nWhy this task: highest-ranked under the active constraint after " + "down-ranking in-flight work; confirm against PyAutoMind priorities " + "(planned.md / active.md) and a Health Agent check before committing.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agents/feature/feature.sh b/agents/feature/feature.sh new file mode 100755 index 0000000..18ef3b4 --- /dev/null +++ b/agents/feature/feature.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# agents/feature/feature.sh — the feature agent (a PyAutoBrain reasoning agent). +# +# The Feature Agent is the *growth function* of PyAutoBrain. It reasons over the +# feature intent stored in PyAutoMind and decides HOW the organism should grow: +# which feature task to work on, how hard it is, whether it must be phased, and +# which development path applies. It does NOT implement code — it produces a +# structured FeatureDecision that the existing workflow consumes: +# +# Mind (PyAutoMind feature/*) -> Feature Agent -> start_dev +# -> start_library / ship_library +# -> start_workspace / ship_workspace +# +# Like the Build Agent, it is a society-of-agents citizen: for risky / multi-repo +# / release-bound work it can consult the sibling Health Agent (and only the +# Health Agent talks to the Heart organ). It consults PyAutoMemory for scientific +# and architectural context — it never invents science when memory has material. +# +# Modes: +# specific read a named PyAutoMind prompt and plan it for start_dev. +# selection choose the best next feature task (no task named). +# difficulty-constrained select under a constraint (--difficulty / --model / +# --budget / --ambitious / --impact). +# +# Usage: +# feature.sh # specific mode +# feature.sh select [--difficulty small|medium|large|easy] +# [--model weak|strong] [--budget] [--ambitious] +# [--impact] [--limit N] +# feature.sh [--json] ... # machine-readable FeatureDecision +# feature.sh [--check-health] ... # also consult the Health Agent +# +# The analysis core lives in _feature.py (stdlib-only, never writes). This script +# resolves the PyAutoMind / PyAutoMemory checkouts and, optionally, the verdict. +# +# Exit codes: 0 produced a decision · 4 no prompts / could-not-resolve · 5 bad +# usage (unknown task / flag). + +set -uo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +source "$HERE/../_common.sh" + +check_health=0 +as_json=0 +forward=() +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + --check-health) check_health=1; shift ;; + --json) as_json=1; shift ;; + *) forward+=("$1"); shift ;; + esac +done + +mind="$(resolve_mind)" || exit 4 + +# PyAutoMemory is optional: the Feature Agent points at the relevant sub-wikis +# even if the checkout is absent, but degrades gracefully rather than failing. +memory="$(resolve_memory 2>/dev/null || true)" + +# Normalise the subcommand. Nothing given -> selection mode. A bare path (or any +# first token that is not a known subcommand or flag) -> specific mode on that +# task, so `feature.sh feature/foo/bar.md` works as the primary entry point. +if [[ ${#forward[@]} -eq 0 ]]; then + forward=(select) +elif [[ "${forward[0]}" != "select" && "${forward[0]}" != "specific" \ + && "${forward[0]}" != --* ]]; then + forward=(specific "${forward[@]}") +fi + +# Optionally consult the sibling Health Agent up front (society-of-agents). This +# does not gate the decision — it annotates it — so the agent still reasons even +# when Heart is unreachable. +if [[ "$check_health" -eq 1 ]]; then + echo "== feature agent: consulting Health Agent for tree readiness ==" + verdict="$(consult_health_agent_verdict)" + echo " readiness verdict: $verdict" + echo +fi + +json_flag=() +[[ "$as_json" -eq 1 ]] && json_flag=(--json) + +exec python3 "$HERE/_feature.py" --mind "$mind" --memory "$memory" \ + "${json_flag[@]}" "${forward[@]}" diff --git a/bin/pyauto-brain b/bin/pyauto-brain index 4d8931d..66054a1 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -7,6 +7,7 @@ # the CLIs feel the same. Each subcommand is a specialist reasoning agent under # agents//. # +# pyauto-brain feature [args] reason over PyAutoMind feature tasks, plan growth # pyauto-brain build [args] coordinate execution: consult health, run Build # pyauto-brain release [args] reason about readiness, then release on green # pyauto-brain health [args] reason over the PyAutoHeart health surface @@ -22,16 +23,18 @@ BRAIN_HOME="$(cd "$(dirname "$_self")/.." && pwd)" AGENTS_DIR="$BRAIN_HOME/agents" declare -A AGENT_SCRIPT=( + [feature]="$AGENTS_DIR/feature/feature.sh" [build]="$AGENTS_DIR/build/build.sh" [release]="$AGENTS_DIR/release/release.sh" [health]="$AGENTS_DIR/health/health.sh" ) declare -A AGENT_DESC=( + [feature]="Reason over PyAutoMind feature tasks: select, size, phase, plan for start_dev" [build]="Coordinate execution: consult the Health Agent, then delegate to PyAutoBuild" [release]="Reason about pyauto-heart readiness, then run the Build release on green" [health]="Reason over the PyAutoHeart monitoring / readiness surface" ) -AGENT_ORDER=(build release health) +AGENT_ORDER=(feature build release health) cmd_help() { if [[ $# -gt 0 ]]; then