From aaf39a917bc2f2698e0eb21112f29b7ce47c45d0 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 16 Jul 2026 09:21:18 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Workspace=20Agent=20=E2=80=94=20the=20V?= =?UTF-8?q?oice,=20conductor=20for=20workspace=20+=20HowTo=20example=20aut?= =?UTF-8?q?horship=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One agent, two audience registers (workspace | howto). Decision-only v0: plan mode emits a WorkspaceDecision (family, target repo, register, placement against the target's real scripts/ tree, sibling to mirror, prose tier, format checklist); survey mode inventories/diffs example catalogues (the autoreduce_workspace bootstrap tool). Never writes. Wiring: dispatcher verb + regenerated command-surface tables, /workspace skill body + wrapper, COMMANDS.md + routing rows, conductor bullet and /docs footnote in AGENTS.md, docs/concepts/agents.md live-conductor set (also restoring hygiene + clone), brain.md agent set, project-discovery symlinks (incl. the wake_up set missed by #115). Hermetic contract tests. Co-Authored-By: Claude Fable 5 --- .claude/commands/wake_up.md | 1 + .claude/commands/workspace.md | 1 + .claude/skills/wake_up | 1 + .claude/skills/workspace | 1 + .codex/skills/wake-up | 1 + .codex/skills/workspace | 1 + AGENTS.md | 18 +- agents/conductors/workspace/AGENTS.md | 108 ++++++++ agents/conductors/workspace/_workspace.py | 321 ++++++++++++++++++++++ agents/conductors/workspace/workspace.sh | 23 ++ bin/pyauto-brain | 5 +- docs/concepts/agents.md | 9 +- skills/COMMANDS.md | 7 +- skills/brain/brain.md | 3 +- skills/route/route.md | 3 +- skills/workspace/SKILL.md | 12 + skills/workspace/workspace.md | 49 ++++ tests/test_workspace_conductor.py | 137 +++++++++ 18 files changed, 693 insertions(+), 8 deletions(-) create mode 120000 .claude/commands/wake_up.md create mode 120000 .claude/commands/workspace.md create mode 120000 .claude/skills/wake_up create mode 120000 .claude/skills/workspace create mode 120000 .codex/skills/wake-up create mode 120000 .codex/skills/workspace create mode 100644 agents/conductors/workspace/AGENTS.md create mode 100644 agents/conductors/workspace/_workspace.py create mode 100755 agents/conductors/workspace/workspace.sh create mode 100644 skills/workspace/SKILL.md create mode 100644 skills/workspace/workspace.md create mode 100644 tests/test_workspace_conductor.py diff --git a/.claude/commands/wake_up.md b/.claude/commands/wake_up.md new file mode 120000 index 0000000..badc3a7 --- /dev/null +++ b/.claude/commands/wake_up.md @@ -0,0 +1 @@ +../../skills/wake_up/wake_up.md \ No newline at end of file diff --git a/.claude/commands/workspace.md b/.claude/commands/workspace.md new file mode 120000 index 0000000..9bfea35 --- /dev/null +++ b/.claude/commands/workspace.md @@ -0,0 +1 @@ +../../skills/workspace/workspace.md \ No newline at end of file diff --git a/.claude/skills/wake_up b/.claude/skills/wake_up new file mode 120000 index 0000000..b318ada --- /dev/null +++ b/.claude/skills/wake_up @@ -0,0 +1 @@ +../../skills/wake_up \ No newline at end of file diff --git a/.claude/skills/workspace b/.claude/skills/workspace new file mode 120000 index 0000000..8ca03b5 --- /dev/null +++ b/.claude/skills/workspace @@ -0,0 +1 @@ +../../skills/workspace \ No newline at end of file diff --git a/.codex/skills/wake-up b/.codex/skills/wake-up new file mode 120000 index 0000000..b318ada --- /dev/null +++ b/.codex/skills/wake-up @@ -0,0 +1 @@ +../../skills/wake_up \ No newline at end of file diff --git a/.codex/skills/workspace b/.codex/skills/workspace new file mode 120000 index 0000000..8ca03b5 --- /dev/null +++ b/.codex/skills/workspace @@ -0,0 +1 @@ +../../skills/workspace \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4a13565..3fa3628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,17 @@ humans invoke identically, so behaviour isn't re-derived from prose each time. formalises). The first conductor whose normal `--auto` mode is **`safe`** (the `refactor` work-type cap in [`AUTONOMY.md`](AUTONOMY.md)). Reuses the Feature Agent's core by import. +- **`agents/conductors/workspace/`** — the *Voice*: the organism's expressive + function — how it speaks to practitioners (workspace examples) and teaches + first-time learners (the `howto` register). Reasons over workspace + HowTo + example authorship and emits a `WorkspaceDecision` — target repo, audience + register (`workspace`|`howto`), placement in the target's real `scripts/` + tree, the sibling example to mirror, prose tier, format checklist — that the + `start_dev → start_workspace → ship_workspace` flow consumes. A `survey` + mode inventories/diffs example catalogues (the newborn-workspace bootstrap + tool). One agent, two registers; the split trigger for a future HowTo agent + is recorded in its AGENTS.md. Decision-only: never edits source, never + writes files. - **`agents/conductors/profiling/`** — the *proprioceptive function*: the organism's sense of its own effort. Heart reads the vitals at rest; this agent runs the stress test — it owns the performance-data lifecycle with @@ -224,6 +235,7 @@ the `/` slash commands. | `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` | +| `workspace` | The voice — the organism's expressive function: plan/survey workspace + HowTo example authorship (workspace\|howto registers) — WorkspaceDecision (never writes) | `bin/pyauto-brain workspace` | | `profiling` | The proprioceptive function — the organism's sense of its own effort: campaign/ingest/triage plans over the autolens_profiling workspace — ProfilingDecision | `bin/pyauto-brain profiling` | | `hygiene` | The maintenance function — the organism's sense of its own upkeep: code-quality debt (dev-loop cost + tidiness), delegating fixes — HygieneDecision | `bin/pyauto-brain hygiene` | | `clone` | The Mitosis Agent (v0: decision only): partition the reference assistant, analyze the domain, emit the CloneDecision — never writes | `bin/pyauto-brain clone` | @@ -262,6 +274,7 @@ the Brain routes it to the right agent; normal usage never says "PyAutoBrain". | `/build` | Build Agent → vitals → Heart → PyAutoBuild | real conductor | | `/health` | Health Agent loop → vitals → Heart | real conductor | | `/refactor` | Refactor Agent → `start_dev [--auto]` (default-safe) | real conductor | +| `/workspace` | Workspace Agent → WorkspaceDecision → `start_dev` → `start_workspace` | real conductor | | `/docs` `/research` | `start_dev` pre-tagged with the work-type | work-type entry* | | `/route ` | infers the work-type and dispatches to one of the above | NL router | | `/wake_up` | sync + clean-slate (local) + gh-API glance (overnight runs, version drift, resume) + `/health`/`/hygiene` → digest; CLI + mobile/codex | composition door | @@ -271,7 +284,10 @@ the Brain routes it to the right agent; normal usage never says "PyAutoBrain". Brain dev-flow with their PyAutoMind work-type fixed (still through the Brain, nothing bypassed). A dedicated conductor is added only on demonstrated need, never for symmetry — the Refactor Agent earned its promotion via the `ideas.md` -backlog bullet, the skill's own recorded follow-up, and the autonomy series. +backlog bullet, the skill's own recorded follow-up, and the autonomy series; +the Workspace Agent earned its via the recurring post-library example work and +the `autoreduce_workspace` birth (`/docs` remains the generic docs entry — +workspace/HowTo *example authorship* specifically routes via `/workspace`). Every command routes **through** the Brain; none replaces it. The command bodies live in `skills//.md`; thin `SKILL.md` wrappers diff --git a/agents/conductors/workspace/AGENTS.md b/agents/conductors/workspace/AGENTS.md new file mode 100644 index 0000000..e932088 --- /dev/null +++ b/agents/conductors/workspace/AGENTS.md @@ -0,0 +1,108 @@ +# Workspace agent + +> **Tier: conductor** — a front-door agent you *drive*. The *Voice* — the +> organism's expressive function: how it speaks to its users. Workspace +> examples are how it addresses practitioners; the `howto` register is how it +> teaches first-time learners (undergrads, new PhD students). It reasons over +> example-authorship intent and emits a `WorkspaceDecision` the +> `start_dev → start_workspace → ship_workspace` flow consumes — it never +> edits source and, in v0, never writes anything at all. + +Grown from demonstrated need, not symmetry: workspace-example authorship is a +recurring follow-up to every library extension, and `autoreduce_workspace` / +`autoreduce_workspace_test` are being born and must mirror the established +workspaces. Founding prompt and one-agent-vs-two assessment: PyAutoMind +`active/workspace_examples_agent.md` (issue PyAutoBrain#116). + +## One agent, two registers + +The examples surface has one format and two audiences: + +- **`workspace`** — reference examples for practitioners (`*_workspace` + repos): standalone, task-oriented, reachable from a search with no prior + reading assumed. +- **`howto`** — narrative teaching for first-time learners (`HowTo*` repos): + cumulative chapters, terms defined at first use, physical intuition before + API. + +Everything else — the contents+docstring script format, markdown/notebook +generation, the `start_workspace → ship_workspace` pipeline, API grounding +against the installed stack, the assistant-training role — is shared, which is +why this is one agent and not two. + +**Split trigger** (recorded so the future decision is cheap): promote the +`howto` register to its own agent only when it develops a decision surface the +workspace register cannot share — curriculum/chapter-continuity planning, +exercise design, or learner-feedback state. Until then it is a register, not +an organism function. + +## Modes + +Both modes are read-only; the agent writes nothing (the Clone Agent's v0 +boundary). + +- **plan** (default) — `pyauto-brain workspace ""` or + `pyauto-brain workspace `: classifies the intent and + emits the `WorkspaceDecision` — library family, target repo, register, + placement (ranked against the target's *real* `scripts/` tree; the repo's + own folders always win), the sibling example to mirror, the prose tier + (WORKFLOW.md tutorial-prose split), the format checklist, and the routing + (`start_dev` → `start_workspace`). A newborn target with no `scripts/` tree + anchors its structure on `autolens_workspace` — the bootstrap case. +- **survey** — `pyauto-brain workspace survey [--against ]`: + inventories a workspace repo's example catalogue (packages + script counts); + `--against` diffs the structure against a sibling — missing/extra packages + and per-package counts. This is the `autoreduce_workspace` bootstrap tool: + `survey autoreduce_workspace --against autolens_workspace` is its birth + checklist. + +`--json` gives the machine-readable decision/survey for both. + +## Format grounding (pointers, never a restated spec) + +The canonical format lives where it is executed, and the decision points at it +rather than duplicating it: + +- the **sibling example script** the decision names — structure is copied from + a living example, never invented; +- **PyAutoBuild's notebook generation** — every top-level docstring becomes a + markdown cell, which is what makes the docstring format load-bearing; +- **`skills/WORKFLOW.md` tutorial-prose split** — example/howto prose is + judgment-tier (the docstrings are the product); `*_workspace_test` scripts + are execution-tier; +- the workspace rules that recur in review: docs are minimal not maximal + (flag/value + one-line note, no ported runnable blocks); new library + `output.yaml` keys are mirrored into workspace configs; every API symbol is + grounded against the installed stack (the PyAuto API gate enforces this). + +## Consult graph + +Like every conductor it consults faculties, never other conductors: the +**memory faculty** when tutorial prose needs scientific or prior-art context +(`pyauto-brain memory ""`). It has no business with Heart — example +authorship is dev work, gated at ship time like any other change. + +## Boundary + +- **Decides and routes; never authors.** The writing happens in the dev flow + the decision points into; prose stays judgment-tier per WORKFLOW.md. +- **Never writes files, never mutates a repo** (v0). If an `--apply` ever + arrives it will scaffold *placement* (empty package dirs mirroring a + sibling), not prose. +- **Conductor, not faculty, deliberately:** it is a front door a human drives + to *start* example work (the Feature Agent's shape), not an opinion another + conductor consults. If a ship-time "does this script meet the format?" + opinion is later needed, that is a separate read-only faculty, not this + agent grown teeth. + +## Running + +``` +bin/pyauto-brain workspace "interferometer example for autoreduce_workspace" +bin/pyauto-brain workspace "new HowToLens chapter on mass profiles" +bin/pyauto-brain workspace draft/docs/workspaces/.md +bin/pyauto-brain workspace survey autoreduce_workspace --against autolens_workspace +``` + +Repos resolve under `PYAUTO_ROOT` (default `~/Code/PyAutoLabs`). Exit codes: +0 decision · 4 inputs unresolvable · 5 bad usage. diff --git a/agents/conductors/workspace/_workspace.py b/agents/conductors/workspace/_workspace.py new file mode 100644 index 0000000..f777654 --- /dev/null +++ b/agents/conductors/workspace/_workspace.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""agents/conductors/workspace/_workspace.py — core for the Workspace Agent. + +The Voice — the organism's expressive function. v0 is DECISION ONLY: it reads +intent (raw text or a PyAutoMind prompt file) or a workspace repo's example +catalogue, and emits a WorkspaceDecision / WorkspaceSurvey. It never edits +source, never writes files — authoring is executed by the ordinary +start_dev → start_workspace → ship_workspace flow this decision routes into. + +Two audience registers, one agent (the founding assessment: +PyAutoMind active/workspace_examples_agent.md): + + workspace reference examples for practitioners (*_workspace repos) + howto narrative teaching for first-time learners (HowTo* repos) + +The format spec is never restated here — the decision points at the canonical +sources (the sibling example script it names, PyAutoBuild's notebook +generation, WORKFLOW.md's tutorial-prose split). + +Stdlib-only. Exit codes: 0 decision · 4 inputs unresolvable · 5 bad usage. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +PYAUTO_ROOT = Path(os.environ.get("PYAUTO_ROOT", Path.home() / "Code" / "PyAutoLabs")) + +# Library package -> (workspace repo, HowTo repo). PyAutoReduce's workspace is +# being born (the founding demonstrated-need case); it has no HowTo yet. +FAMILIES = { + "autofit": ("autofit_workspace", "HowToFit"), + "autogalaxy": ("autogalaxy_workspace", "HowToGalaxy"), + "autolens": ("autolens_workspace", "HowToLens"), + "autoreduce": ("autoreduce_workspace", None), +} + +# When a target workspace does not exist on disk yet (a newborn like +# autoreduce_workspace), structure suggestions are anchored on this sibling. +FALLBACK_SIBLING = "autolens_workspace" + +# Signals that the reader is there to LEARN, not to look something up — +# they flip the register to howto (a HowTo* target repo always does). +HOWTO_WORDS = ( + "howto", "chapter", "first-time", "first time", "beginner", "undergrad", + "new phd", "lecture", "teaching", "learner", "course", +) + +# Keyword -> example-package hints, checked against the target repo's real +# scripts/ tree (the repo's own folders always win; this list only ranks them). +PLACEMENT_HINTS = ( + ("interferometer", ("interferometer", "alma", "visibilit", "uv-plane")), + ("point_source", ("point_source", "point source", "quasar", "supernova")), + ("multi", ("multi", "multi-band", "multiband", "multi-dataset")), + ("group", ("group",)), + ("cluster", ("cluster",)), + ("weak", ("weak lensing", "weak-lensing", "weak")), + ("guides", ("guide", "aggregator", "results", "api overview")), + ("imaging", ("imaging", "ccd", "image data")), +) + +CHECKLIST_SHARED = [ + "contents + docstring format: every top-level docstring becomes a markdown " + "cell at notebook generation — mirror the named sibling script, do not " + "invent structure", + "docs are minimal, not maximal: a flag/value plus a one-line note, never a " + "ported runnable block", + "new library output.yaml keys must be mirrored into the workspace config", + "ground every API symbol against the installed stack (the PyAuto API gate " + "blocks stale symbols)", +] +CHECKLIST_REGISTER = { + "workspace": [ + "standalone and task-oriented: a practitioner lands here from a search " + "— no dependence on having read anything else", + ], + "howto": [ + "narrative and cumulative: assumes only the previous chapters; define " + "every term at first use for a first-time learner", + "prefer physical intuition before API — the reader is here to learn " + "the subject, not the package", + ], +} + + +def fail(code, msg): + print(f"workspace: {msg}", file=sys.stderr) + sys.exit(code) + + +def read_intent(arg): + """The intent text: a PyAutoMind prompt path if it resolves, else raw text.""" + for base in (Path(arg), PYAUTO_ROOT / "PyAutoMind" / arg): + if base.is_file(): + return base.read_text(encoding="utf-8"), str(base) + return arg, None + + +def detect_register(text, target_repo): + if target_repo and target_repo.startswith("HowTo"): + return "howto" + low = text.lower() + if any(w in low for w in HOWTO_WORDS): + return "howto" + return "workspace" + + +def detect_family(text): + """The library family the intent talks about, by longest-match repo or + package mention; None when nothing matches.""" + low = text.lower() + # Explicit repo mentions win (workspace, HowTo or library repo). + for pkg, (ws, howto) in FAMILIES.items(): + mentions = [ws, howto or "", f"pyauto{pkg[4:]}"] + if any(m and m.lower() in low for m in mentions): + return pkg + # Bare package words, most specific (longest) first so "autolens" is not + # claimed by a shorter overlapping name. + for pkg in sorted(FAMILIES, key=len, reverse=True): + if re.search(rf"\b{pkg}\b", low): + return pkg + return None + + +def scripts_root(repo): + return PYAUTO_ROOT / repo / "scripts" + + +def example_packages(repo): + root = scripts_root(repo) + if not root.is_dir(): + return [] + return sorted( + p.name for p in root.iterdir() if p.is_dir() and not p.name.startswith("_") + ) + + +def suggest_placement(text, packages): + low = text.lower() + for package, hints in PLACEMENT_HINTS: + if package in packages and any(h in low for h in hints): + return package + # HowTo repos: an existing chapter mentioned by number. + m = re.search(r"chapter[_ ](\d+)", low) + if m: + for package in packages: + if package.startswith(f"chapter_{m.group(1)}"): + return package + return None + + +def build_decision(args): + text, prompt_path = read_intent(args.intent) + family = detect_family(text) + if family is None: + fail(4, "cannot resolve a library family (autofit/autogalaxy/autolens/" + "autoreduce) from the intent — name the library or workspace repo") + workspace_repo, howto_repo = FAMILIES[family] + + # An explicitly mentioned repo is the target; otherwise the register picks + # between the family's workspace and HowTo repos. + low = text.lower() + explicit = next( + (r for r in (howto_repo, workspace_repo) if r and r.lower() in low), None + ) + register = detect_register(text, explicit) + target = explicit or (howto_repo if register == "howto" else workspace_repo) + notes = [] + if register == "howto" and target == workspace_repo and howto_repo is None: + notes.append(f"family '{family}' has no HowTo repo yet — howto register " + f"targets the workspace repo until one is born") + + packages = example_packages(target) + anchor = target + if not packages: + anchor = FALLBACK_SIBLING + packages = example_packages(anchor) + notes.append(f"{target} has no scripts/ tree on disk — structure " + f"anchored on {anchor} (the newborn mirrors it)") + placement = suggest_placement(text, packages) + + sibling = None + if placement: + sibling = f"{anchor}/scripts/{placement}/" + + prose_tier = "judgment" + if "_workspace_test" in low or "workspace_test" in low: + prose_tier = "execution" + notes.append("workspace_test scripts are code-heavy/doc-light — " + "execution tier per WORKFLOW.md") + + title = text.strip().splitlines()[0].lstrip("# ").strip()[:88] + return { + "title": title, + "prompt_path": prompt_path, + "family": family, + "target_repo": target, + "register": register, + "placement": placement, + "example_packages": packages, + "sibling_to_mirror": sibling, + "prose_tier": prose_tier, + "format_checklist": CHECKLIST_SHARED + CHECKLIST_REGISTER[register], + "format_grounding": [ + "the sibling example script (structure is copied, never invented)", + "PyAutoBuild notebook generation (docstring cells -> .ipynb)", + "PyAutoBrain/skills/WORKFLOW.md — tutorial-prose split", + ], + "notes": notes, + "next_action": ( + "route into start_dev (workflow: workspace) → start_workspace → " + "author → ship_workspace; this decision writes nothing" + ), + } + + +def print_decision(d): + print("== WorkspaceDecision (v0 — decision only, writes nothing) ==") + print(f"Intent: {d['title']}") + if d["prompt_path"]: + print(f"Prompt file: {d['prompt_path']}") + print(f"Family: {d['family']}") + print(f"Target repo: {d['target_repo']}") + print(f"Register: {d['register']}") + print(f"Placement: {d['placement'] or '(no package matched — pick from below)'}") + print(f"Example packages: {', '.join(d['example_packages']) or '(none found)'}") + print(f"Sibling to mirror: {d['sibling_to_mirror'] or '(pick one after placement)'}") + print(f"Prose tier: {d['prose_tier']}") + print("Format checklist:") + for item in d["format_checklist"]: + print(f" - {item}") + print("Format grounding (read these, never restate them):") + for item in d["format_grounding"]: + print(f" - {item}") + for n in d["notes"]: + print(f"Note: {n}") + print(f"Next action: {d['next_action']}") + + +def count_scripts(path): + return sum(1 for _ in path.rglob("*.py")) + + +def build_survey(args): + target = args.repo + root = scripts_root(target) + packages = {p: count_scripts(scripts_root(target) / p) + for p in example_packages(target)} + if not root.is_dir(): + fail(4, f"'{target}' has no scripts/ tree under {PYAUTO_ROOT} — nothing " + f"to survey (use `--against` on the sibling to plan a newborn)") + + survey = { + "repo": target, + "packages": packages, + "total_scripts": sum(packages.values()), + } + if args.against: + sibling = {p: count_scripts(scripts_root(args.against) / p) + for p in example_packages(args.against)} + if not sibling: + fail(4, f"sibling '{args.against}' has no scripts/ tree under {PYAUTO_ROOT}") + survey["against"] = args.against + survey["missing_packages"] = sorted(set(sibling) - set(packages)) + survey["extra_packages"] = sorted(set(packages) - set(sibling)) + survey["shared_packages"] = { + p: {"repo": packages[p], "sibling": sibling[p]} + for p in sorted(set(packages) & set(sibling)) + } + return survey + + +def print_survey(s): + print(f"== WorkspaceSurvey — {s['repo']} ==") + print(f"Total scripts: {s['total_scripts']}") + print("Packages:") + for p, n in s["packages"].items(): + print(f" - {p:20s} {n:4d} script(s)") + if "against" in s: + print(f"Against sibling: {s['against']}") + for p in s["missing_packages"]: + print(f" ✗ missing package: {p}") + for p in s["extra_packages"]: + print(f" + extra package: {p}") + for p, c in s["shared_packages"].items(): + print(f" = {p:20s} {c['repo']:4d} vs {c['sibling']:4d}") + + +def main(): + argv = sys.argv[1:] + if argv and argv[0] == "survey": + parser = argparse.ArgumentParser(prog="workspace survey") + parser.add_argument("repo", help="workspace/HowTo repo to inventory") + parser.add_argument("--against", default=None, + help="sibling workspace repo to diff structure against") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv[1:]) + survey = build_survey(args) + print(json.dumps(survey, indent=2)) if args.json else print_survey(survey) + sys.exit(0) + + parser = argparse.ArgumentParser(prog="workspace", description=__doc__) + parser.add_argument("intent", nargs="?", default=None, + help="raw intent text, or a PyAutoMind prompt path") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + if not args.intent or not args.intent.strip(): + fail(5, "no intent given — pass raw text, a PyAutoMind prompt path, or " + "`survey `") + decision = build_decision(args) + print(json.dumps(decision, indent=2)) if args.json else print_decision(decision) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/agents/conductors/workspace/workspace.sh b/agents/conductors/workspace/workspace.sh new file mode 100755 index 0000000..16bdb82 --- /dev/null +++ b/agents/conductors/workspace/workspace.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# agents/conductors/workspace/workspace.sh — the Workspace Agent (a PyAutoBrain +# reasoning conductor). Organism-facing name: the Voice — the organism's +# expressive function: how it speaks to practitioners (workspace examples) and +# teaches first-time learners (the howto register). +# +# v0 is DECISION ONLY: plan mode emits a WorkspaceDecision (target repo, +# register, placement, sibling to mirror, format checklist, routing); survey +# mode inventories a workspace repo's example catalogue. It writes nothing — +# authoring runs through start_dev → start_workspace → ship_workspace. +# +# Usage: +# workspace.sh "" # plan (default mode) +# workspace.sh # plan from a filed prompt +# workspace.sh survey [--against ] # catalogue inventory/diff +# workspace.sh --json ... # machine-readable output + +set -uo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +source "$HERE/../../_common.sh" + +exec python3 "$HERE/_workspace.py" "$@" diff --git a/bin/pyauto-brain b/bin/pyauto-brain index 6c6e8c2..c2fb458 100755 --- a/bin/pyauto-brain +++ b/bin/pyauto-brain @@ -14,6 +14,7 @@ # # pyauto-brain feature [args] (conductor) reason over PyAutoMind tasks, plan growth # pyauto-brain refactor [args] (conductor) plan behaviour-preserving restructuring (default-safe) +# pyauto-brain workspace [args] (conductor) the Voice: plan/survey workspace + HowTo example authorship — WorkspaceDecision (writes nothing) # pyauto-brain profiling [args] (conductor) campaign/ingest/triage plans over the profiling workspace # pyauto-brain hygiene [args] (conductor) code-quality upkeep: perf/tidy/noise/deps/docs — HygieneDecision (modes staged) # pyauto-brain clone [args] (conductor) v0: analyze a library+workspace against the reference assistant — CloneDecision (writes nothing) @@ -49,6 +50,7 @@ declare -A AGENT_SCRIPT=( [feature]="$CONDUCTORS_DIR/feature/feature.sh" [bug]="$CONDUCTORS_DIR/bug/bug.sh" [refactor]="$CONDUCTORS_DIR/refactor/refactor.sh" + [workspace]="$CONDUCTORS_DIR/workspace/workspace.sh" [profiling]="$CONDUCTORS_DIR/profiling/profiling.sh" [hygiene]="$CONDUCTORS_DIR/hygiene/hygiene.sh" [clone]="$CONDUCTORS_DIR/clone/clone.sh" @@ -65,6 +67,7 @@ declare -A AGENT_DESC=( [feature]="Reason over PyAutoMind feature tasks: select, size, phase, plan for start_dev" [bug]="The immune system: classify a bug/regression/Heart finding, locate the fix, plan the repair" [refactor]="The renewal function: plan behaviour-preserving restructuring — RefactorDecision; default-safe under --auto" + [workspace]="The voice — the organism's expressive function: plan/survey workspace + HowTo example authorship (workspace|howto registers) — WorkspaceDecision (never writes)" [profiling]="The proprioceptive function — the organism's sense of its own effort: campaign/ingest/triage plans over the autolens_profiling workspace — ProfilingDecision" [hygiene]="The maintenance function — the organism's sense of its own upkeep: code-quality debt (dev-loop cost + tidiness), delegating fixes — HygieneDecision" [clone]="The Mitosis Agent (v0: decision only): partition the reference assistant, analyze the domain, emit the CloneDecision — never writes" @@ -78,7 +81,7 @@ declare -A AGENT_DESC=( ) # Conductors are the front doors a human drives; faculties are consulted (and # runnable read-only). Both are dispatchable, but the menu groups them by tier. -CONDUCTOR_ORDER=(intake feature bug refactor profiling hygiene clone build release health) +CONDUCTOR_ORDER=(intake feature bug refactor workspace profiling hygiene clone build release health) FACULTY_ORDER=(vitals review memory samplers) AGENT_ORDER=("${CONDUCTOR_ORDER[@]}" "${FACULTY_ORDER[@]}") diff --git a/docs/concepts/agents.md b/docs/concepts/agents.md index 75e1d77..6b68ade 100644 --- a/docs/concepts/agents.md +++ b/docs/concepts/agents.md @@ -14,9 +14,12 @@ question: **does it act, or only opine?** The live conductor set: **intake** (raw idea → formal Mind prompt), **feature** (plan the next growth task), **bug** (classify and route a -repair), **refactor** (behaviour-preserving restructuring), **profiling** -(the organism's sense of its own effort), **build**, **release**, and -**health** (the clinician loop toward GREEN). The faculties behind them: +repair), **refactor** (behaviour-preserving restructuring), **workspace** +(the Voice — plan workspace + HowTo example authorship), **profiling** +(the organism's sense of its own effort), **hygiene** (the organism's sense +of its own upkeep), **clone** (reproduce a mature assistant into a new +cell), **build**, **release**, and **health** (the clinician loop toward +GREEN). The faculties behind them: **vitals** (the only component that talks to Heart), **review** (the automatic-review leg of the ship gate), **memory** (cited digests over the Memory organ), and **samplers** (domain expertise for the live instance). diff --git a/skills/COMMANDS.md b/skills/COMMANDS.md index 10de0da..40728b8 100644 --- a/skills/COMMANDS.md +++ b/skills/COMMANDS.md @@ -1,7 +1,7 @@ # PyAutoBrain command surface — shared reference The short verb commands (`/intake`, `/feature`, `/build`, `/health`, `/bug`, -`/refactor`, `/profiling`, `/hygiene`, `/docs`, `/research`, `/route`, `/brain`) are a thin, human-friendly **veneer** +`/refactor`, `/workspace`, `/profiling`, `/hygiene`, `/docs`, `/research`, `/route`, `/brain`) are a thin, human-friendly **veneer** over the PyAutoBrain router (`bin/pyauto-brain`). This file is the shared context every command file points at, so each command body stays a few lines long. @@ -33,6 +33,7 @@ readiness gate, or execution — those belong to the organs. | `/feature` | Feature Agent | `bin/pyauto-brain feature` → `start_dev` → `ship_*` | | `/bug` | Bug Agent | `bin/pyauto-brain bug` → `start_dev` → `ship_*` (health mode → vitals + Heart issues) | | `/refactor` | Refactor Agent | `bin/pyauto-brain refactor` → `start_dev [--auto]` → `ship_*` (behaviour-preserving; default-safe) | +| `/workspace` | Workspace Agent | `bin/pyauto-brain workspace` → WorkspaceDecision (plan/survey example authorship) → `start_dev` → `start_workspace` → `ship_workspace` | | `/profiling` | Profiling Agent | `bin/pyauto-brain profiling` → campaign/ingest/triage plans over the autolens_profiling workspace | | `/hygiene` | Hygiene Agent | `bin/pyauto-brain hygiene` → perf/tidy/noise/deps/docs upkeep plans; delegates fixes to refactor/bug/feature (modes staged) | | `/build` | Build Agent | `bin/pyauto-brain build` → vitals faculty → Heart → PyAutoBuild | @@ -47,6 +48,10 @@ the Brain (via `start_dev` → Feature Agent), so nothing is bypassed: | `/docs` | `docs/` | | `/research` | `research/` | +(`/docs` remains the generic docs work-type entry; workspace/HowTo *example +authorship* specifically now has a real conductor — the Workspace Agent, +tier 1 above.) + (`/refactor` graduated to a real conductor — the Refactor Agent, `agents/conductors/refactor/` — and now sits in tier 1 above.) diff --git a/skills/brain/brain.md b/skills/brain/brain.md index 3bf7035..b907c7d 100644 --- a/skills/brain/brain.md +++ b/skills/brain/brain.md @@ -9,7 +9,8 @@ Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. ## Do - `/brain [args...]` where `agent ∈ {intake, feature, bug, refactor, - profiling, clone, build, release, health, vitals, review, memory, samplers}` → + workspace, profiling, hygiene, clone, build, release, health, vitals, review, + memory, samplers}` → run `bin/pyauto-brain [args...]` and report the output verbatim. - `/brain help [agent]` → run `bin/pyauto-brain help [agent]`. - `/brain ` with no known agent → defer to **`/route`**. diff --git a/skills/route/route.md b/skills/route/route.md index 3e7e6bc..c17823e 100644 --- a/skills/route/route.md +++ b/skills/route/route.md @@ -19,7 +19,8 @@ Infer the work-type from the request using the taxonomy in | failing tests, readiness, "is it safe / green?" | `/health` | | a regression, crash, or wrong output | `/bug` | | restructuring with no behaviour change | `/refactor` | -| documentation, examples, notebooks, tutorials | `/docs` | +| authoring a workspace or HowTo example/tutorial | `/workspace` | +| other documentation, notebooks, docstrings | `/docs` | | investigation, design, or scientific background | `/research` | Examples: *"Fix failing tests"* → `/bug` (or `/health` if it's a readiness sweep); diff --git a/skills/workspace/SKILL.md b/skills/workspace/SKILL.md new file mode 100644 index 0000000..df12362 --- /dev/null +++ b/skills/workspace/SKILL.md @@ -0,0 +1,12 @@ +--- +name: workspace +description: Plan workspace or HowTo example authorship through the PyAutoBrain Workspace Agent — target repo, audience register, placement, sibling to mirror, format checklist — or survey a workspace repo's example catalogue. Decision-only; authoring itself runs through start_dev → start_workspace. +--- + +# Workspace + +Follow [`workspace.md`](workspace.md) exactly. The Workspace Agent (the +*Voice*) reasons over workspace + HowTo example authorship and emits a +`WorkspaceDecision` the dev flow consumes — it never edits source and never +writes files. Keep authoring in `start_workspace`/`ship_workspace` and health +in Heart. diff --git a/skills/workspace/workspace.md b/skills/workspace/workspace.md new file mode 100644 index 0000000..6eab26f --- /dev/null +++ b/skills/workspace/workspace.md @@ -0,0 +1,49 @@ +# /workspace — plan example authorship (via the Brain Workspace Agent) + +Reason over **workspace + HowTo example authorship** — the organism's *Voice* — +via PyAutoBrain's **Workspace Agent**. You never name the Brain; this command +is the door. + +Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. The agent's full +docs: `bin/pyauto-brain help workspace`. + +## Do + +1. Run the agent on the intent (raw text or a filed PyAutoMind prompt): + + ```bash + bin/pyauto-brain workspace "" + bin/pyauto-brain workspace + ``` + + This emits a **WorkspaceDecision** — library family, target repo, audience + register (`workspace` for practitioner examples, `howto` for first-time + learners), placement in the target's real `scripts/` tree, the sibling + example to mirror, the prose tier, and the format checklist. It is a dry + run every time: the agent **writes nothing**. +2. Review the decision with the user — correct the target, register or + placement in conversation. +3. Route the agreed work into the dev flow: `/start_dev` (workflow: + workspace) → `/start_workspace` → author → `/ship_workspace`. The decision's + checklist and sibling pointer ride along in the plan; example/howto prose + stays judgment-tier per `skills/WORKFLOW.md`. + +## Survey mode (catalogue inventory) + +```bash +bin/pyauto-brain workspace survey [--against ] +``` + +Read-only inventory of a workspace repo's example catalogue (packages + +script counts); `--against` diffs the structure against a sibling — the +newborn-workspace bootstrap tool (e.g. +`survey autoreduce_workspace --against autolens_workspace`). + +## Boundary + +- **Decides and routes; never authors.** All writing happens in the dev flow; + this command never bypasses `start_dev`. +- One agent, two registers (`workspace` | `howto`); the split trigger for a + future dedicated HowTo agent is recorded in the agent's + `agents/conductors/workspace/AGENTS.md`. +- `--json` gives the machine-readable decision/survey. diff --git a/tests/test_workspace_conductor.py b/tests/test_workspace_conductor.py new file mode 100644 index 0000000..8eaa3e3 --- /dev/null +++ b/tests/test_workspace_conductor.py @@ -0,0 +1,137 @@ +"""Contract tests for the workspace conductor's CLI footing. + +Hermetic: PYAUTO_ROOT points at a fabricated temp workspace layout, so +decisions/surveys are asserted structurally without depending on the state of +the real checkouts. The conductor is decision-only — every test also relies on +it never writing anything (asserted explicitly in test_never_writes). +""" + +import json +import os +import subprocess +from pathlib import Path + +BRAIN_HOME = Path(__file__).resolve().parents[1] +BRAIN = BRAIN_HOME / "bin" / "pyauto-brain" + +DECISION_KEYS = { + "title", "prompt_path", "family", "target_repo", "register", "placement", + "example_packages", "sibling_to_mirror", "prose_tier", "format_checklist", + "format_grounding", "notes", "next_action", +} + + +def _run(args, root): + env = {**os.environ, "PYAUTO_ROOT": str(root)} + return subprocess.run( + [str(BRAIN), "workspace", *args], + capture_output=True, text=True, env=env, + ) + + +def _fabricate_workspace(root, repo, packages): + for package, n_scripts in packages.items(): + d = root / repo / "scripts" / package + d.mkdir(parents=True) + for i in range(n_scripts): + (d / f"example_{i}.py").write_text("# example\n") + + +def test_plan_json_is_a_complete_workspace_decision(tmp_path): + _fabricate_workspace(tmp_path, "autolens_workspace", {"imaging": 2}) + r = _run(["--json", "an imaging example for autolens_workspace"], tmp_path) + assert r.returncode == 0, r.stderr + d = json.loads(r.stdout) + assert set(d) == DECISION_KEYS + assert d["family"] == "autolens" + assert d["target_repo"] == "autolens_workspace" + assert d["register"] == "workspace" + assert d["placement"] == "imaging" + assert d["sibling_to_mirror"] == "autolens_workspace/scripts/imaging/" + assert d["prose_tier"] == "judgment" + assert d["format_checklist"] + + +def test_howto_register_from_target_repo(tmp_path): + r = _run(["--json", "new HowToLens chapter on mass profiles"], tmp_path) + assert r.returncode == 0, r.stderr + d = json.loads(r.stdout) + assert d["family"] == "autolens" + assert d["target_repo"] == "HowToLens" + assert d["register"] == "howto" + # The howto register carries its own checklist items on top of the shared ones. + assert any("first-time learner" in item for item in d["format_checklist"]) + + +def test_howto_register_from_teaching_words(tmp_path): + r = _run(["--json", "an autogalaxy tutorial for undergrads"], tmp_path) + d = json.loads(r.stdout) + assert d["register"] == "howto" + assert d["target_repo"] == "HowToGalaxy" + + +def test_newborn_workspace_anchors_on_fallback_sibling(tmp_path): + _fabricate_workspace(tmp_path, "autolens_workspace", {"imaging": 1, "guides": 1}) + r = _run(["--json", "an imaging example for autoreduce_workspace"], tmp_path) + d = json.loads(r.stdout) + assert d["family"] == "autoreduce" + assert d["target_repo"] == "autoreduce_workspace" + assert d["placement"] == "imaging" + assert d["sibling_to_mirror"] == "autolens_workspace/scripts/imaging/" + assert any("anchored on autolens_workspace" in n for n in d["notes"]) + + +def test_plan_reads_a_mind_prompt_path(tmp_path): + prompt = ( + tmp_path / "PyAutoMind" / "draft" / "docs" / "workspaces" / "example.md" + ) + prompt.parent.mkdir(parents=True) + prompt.write_text("# Interferometer example\n\nAdd it to @autolens_workspace.\n") + _fabricate_workspace(tmp_path, "autolens_workspace", {"interferometer": 1}) + r = _run(["--json", "draft/docs/workspaces/example.md"], tmp_path) + d = json.loads(r.stdout) + assert d["prompt_path"] == str(prompt) + assert d["placement"] == "interferometer" + + +def test_unresolvable_family_exits_4(tmp_path): + r = _run(["--json", "write some documentation please"], tmp_path) + assert r.returncode == 4 + assert "cannot resolve" in r.stderr + + +def test_no_intent_exits_5(tmp_path): + r = _run([], tmp_path) + assert r.returncode == 5 + + +def test_survey_inventories_and_diffs_against_sibling(tmp_path): + _fabricate_workspace( + tmp_path, "autolens_workspace", {"imaging": 3, "interferometer": 2, "guides": 1} + ) + _fabricate_workspace(tmp_path, "autoreduce_workspace", {"imaging": 1}) + r = _run( + ["survey", "autoreduce_workspace", "--against", "autolens_workspace", "--json"], + tmp_path, + ) + assert r.returncode == 0, r.stderr + s = json.loads(r.stdout) + assert s["repo"] == "autoreduce_workspace" + assert s["total_scripts"] == 1 + assert s["missing_packages"] == ["guides", "interferometer"] + assert s["extra_packages"] == [] + assert s["shared_packages"]["imaging"] == {"repo": 1, "sibling": 3} + + +def test_survey_missing_repo_exits_4(tmp_path): + r = _run(["survey", "autoreduce_workspace"], tmp_path) + assert r.returncode == 4 + + +def test_never_writes(tmp_path): + _fabricate_workspace(tmp_path, "autolens_workspace", {"imaging": 1}) + before = sorted(str(p) for p in tmp_path.rglob("*")) + _run(["an imaging example for autolens_workspace"], tmp_path) + _run(["survey", "autolens_workspace"], tmp_path) + after = sorted(str(p) for p in tmp_path.rglob("*")) + assert before == after