diff --git a/bin/project_discovery.py b/bin/project_discovery.py index 8c6db39..5e149dd 100644 --- a/bin/project_discovery.py +++ b/bin/project_discovery.py @@ -273,13 +273,65 @@ def protected_conflicts(path: Path, wanted: dict[str, tuple[str, str]]) -> list[ return 2 if has_conflict and write else int(drift and not write) +def starter(root: Path, write: bool) -> int: + """Expose registered Brain/Mind skills locally without changing either repo.""" + root = root.resolve() + if not root.is_dir(): + raise ValueError('starter workspace root must exist') + registry = manifest_paths(root) + wanted = {} + for name in ('PyAutoBrain', 'PyAutoMind'): + if name not in registry: + raise ValueError(f'starter requires registered {name}') + path = repo_path(root, name) + if not (path / 'skills').is_dir(): + raise ValueError(f'{name}: skills directory missing') + discovered = expected(name, path, 'organ') + selected = {rel: data for rel, (kind, data) in discovered.items() + if rel.startswith('.claude/skills/') and kind == 'symlink'} + if not selected: + raise ValueError(f'{name}: no discoverable skills') + for rel, target in selected.items(): + if rel in wanted: + raise ValueError(f'starter skill collision: {rel}') + source = ((path / rel).parent / target).resolve() + if not source.exists(): + raise ValueError(f'{name}: missing canonical skill {source}') + wanted[rel] = os.path.relpath(source.resolve(), (root / rel).parent) + # Preflight every path, including parents, before writing any link. + for parent in (root / '.claude', root / '.claude/skills'): + if parent.is_symlink() or (parent.exists() and not parent.is_dir()): + raise ValueError(f'protected starter discovery parent: {parent}') + missing = [] + for rel, target in wanted.items(): + item = root / rel + if item.is_symlink(): + if os.readlink(item) != target: + raise ValueError(f'protected starter discovery link: {item}') + elif item.exists(): + raise ValueError(f'protected starter discovery entry: {item}') + else: + missing.append(rel) + if write: + (root / '.claude/skills').mkdir(parents=True, exist_ok=True) + for rel in missing: + (root / rel).symlink_to(wanted[rel]) + print(f"{'READY' if write or not missing else 'MISSING'}: starter discovery: " + f'{len(wanted)} skills, {len(missing)} ' + ('created' if write else 'missing')) + return int(bool(missing) and not write) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('mode', choices=('write', 'check')) + parser.add_argument('mode', choices=('write', 'check', 'starter-write', 'starter-check')) parser.add_argument('repos', nargs='*') parser.add_argument('--root', type=Path, default=Path(os.environ.get('PYAUTO_ROOT', '.'))) args = parser.parse_args() try: + if args.mode.startswith('starter-'): + if args.repos: + raise ValueError('starter modes select registered Brain and Mind only; omit repo arguments') + return starter(args.root, args.mode == 'starter-write') return run(args.root, args.repos, args.mode == 'write') except (ValueError, OSError) as error: print(f'project discovery: {error}', file=sys.stderr) diff --git a/docs/adoption/guide.md b/docs/adoption/guide.md index fa6e499..904a634 100644 --- a/docs/adoption/guide.md +++ b/docs/adoption/guide.md @@ -99,3 +99,102 @@ quarter), with no compatibility promises on `main`. Fork-and-pull at your own pace; pin what you depend on; expect churn. Issues and PRs are welcome, but the pace is set by the live instance's needs. Each organ carries this disclaimer in its `CONTRIBUTING.md`. + +## Minimal local pilot: Claude skill discovery + +This opt-in path connects the starter pieces without running the user-wide +installer. It prepares local skill discovery; it does not install or authenticate +Claude Code, publish anything, or certify the full development/release workflow. +Use an already installed Claude Code and Python with PyYAML. Heart remains +required before shipping; discovering a skill never bypasses its own gates. + +Use one workspace directory with flat `PyAutoBrain`, `PyAutoScientist` and +`PyAutoMind` directories. Keep compatible framework checkouts pinned to revisions +you have validated. The local directory names are framework lookup slots; the +Scientist display name and GitHub repository names remain your choices. + +1. From the workspace root, generate a new empty Mind using a trusted existing + Mind source checkout (the destination must not already exist): + + ```sh + python3 /path/to/Mind-source/scripts/spawn.py --minimal-mind --write PyAutoMind + ``` + +2. Populate **your** `PyAutoMind/repos.yaml`. Replace these fictitious GitHub + identities with your own or the explicit framework sources you use. Registration + does not create repositories, verify access or activate an organ: + + ```yaml + categories: + organ: {} + repos: + PyAutoBrain: + github: ExampleResearch/PyAutoBrain + category: organ + PyAutoMind: + github: ExampleResearch/ResearchPlans + category: organ + PyAutoScientist: + github: ExampleResearch/MiraScientist + category: organ + ``` + +3. Create user-owned Scientist configuration in a new directory: + + ```sh + python3 PyAutoScientist/scripts/starter.py instance --repos PyAutoMind/repos.yaml \ + --name "Mira Scientist" --repo PyAutoScientist \ + --pages-url https://example.invalid/mira/ --apply + ``` + +4. Preview missing workspace-local links, write them, then check them: + + ```sh + python3 PyAutoBrain/bin/project_discovery.py starter-check --root . + python3 PyAutoBrain/bin/project_discovery.py starter-write --root . + python3 PyAutoBrain/bin/project_discovery.py starter-check --root . + ``` + + The first check exits 1 while links are missing. Write creates relative links + in **this workspace's** `.claude/skills` to the registered Brain and Mind + skills. It never writes user-wide settings, either source repository, or a + Codex discovery directory. Existing unrelated skills are preserved. Any + conflicting destination or symlinked discovery parent rejects the whole plan + before writing. Repeating write is a no-op when links match. Stale or custom + links are not deleted; inspect conflicts yourself after moving checkouts. + +5. Start a fresh Claude session **at the workspace root**, scoped to this Mind: + + ```sh + PYAUTO_ROOT="$PWD" claude + ``` + + Give it this first request: + + > Read instance/SETUP.md, PyAutoMind/AGENTS.md, PyAutoMind/repos.yaml and + > PyAutoBrain/AGENTS.md. Inspect existing task state before changing anything. + > Help me connect one existing project using PyAutoScientist/PROJECTS.md. + > Ask for missing identity, purpose and visibility; don't assume an assistant. + > Keep source material in its owning project. Validate and show a local preview. + > Don't publish, install optional organs, run science or change access. + +6. Validate the profile and render locally, using the same explicit Brain source: + + ```sh + PYAUTO_BRAIN="$PWD/PyAutoBrain" python3 PyAutoScientist/scripts/organism_board.py \ + --instance instance/instance.json --repos PyAutoMind/repos.yaml --check-instance + PYAUTO_BRAIN="$PWD/PyAutoBrain" python3 PyAutoScientist/scripts/organism_board.py \ + --instance instance/instance.json --repos PyAutoMind/repos.yaml --html + ``` + + Save the HTML output to a scratch file. Project entries are private by default; + only explicitly public projects/resources appear in the dashboard projection. + Memory, assistants and other organs remain optional. Keep canonical identities + in Mind, project relationships in the instance, and project content at source. + +The acceptance checkpoint is a real fresh session discovering the local skills, +reading the correct Mind and preserving records while connecting a project. CLI +and filesystem tests alone do not prove that conversational behavior; record it +before calling an adopter's harness validated. If Claude does not discover the +local skills, stop and diagnose its configuration rather than running the global +installer or silently falling back to another Scientist's state. diff --git a/tests/test_starter_discovery.py b/tests/test_starter_discovery.py new file mode 100644 index 0000000..9321dbb --- /dev/null +++ b/tests/test_starter_discovery.py @@ -0,0 +1,87 @@ +"""Workspace-local discovery preserves user state and needs no optional organs.""" +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('starter_discovery', ROOT / 'bin/project_discovery.py') +discovery = importlib.util.module_from_spec(spec) +spec.loader.exec_module(discovery) + + +@pytest.fixture +def workspace(tmp_path): + for repo, skill in [('PyAutoBrain', 'feature'), ('PyAutoMind', 'create_issue')]: + path = tmp_path / repo / 'skills' / skill + path.mkdir(parents=True) + (path / 'SKILL.md').write_text(f'---\nname: {skill.replace("_", "-")}\ndescription: Example skill\n---\nRead local instructions.\n') + (tmp_path / 'PyAutoMind/repos.yaml').write_text('repos:\n PyAutoBrain:\n category: organ\n PyAutoMind:\n category: organ\n') + return tmp_path + + +def test_check_write_idempotence_and_preservation(workspace, tmp_path): + assert discovery.starter(workspace, False) == 1 + assert not (workspace / '.claude').exists() + assert discovery.starter(workspace, True) == 0 + target = workspace / '.claude/skills/feature' + assert target.is_symlink() + assert not target.readlink().is_absolute() + assert target.resolve() == workspace / 'PyAutoBrain/skills/feature' + personal = workspace / '.claude/skills/personal' + personal.mkdir() + (personal / 'SKILL.md').write_text('user owned') + assert discovery.starter(workspace, False) == 0 + assert discovery.starter(workspace, True) == 0 + assert (personal / 'SKILL.md').read_text() == 'user owned' + assert not (workspace / '.codex').exists() + assert not (workspace / 'PyAutoBrain/.claude').exists() + assert not (workspace / 'PyAutoMind/.claude').exists() + + +@pytest.mark.parametrize('kind', ['file', 'directory', 'symlink', 'parent']) +def test_conflict_prevents_all_writes(workspace, kind): + dest = workspace / '.claude/skills/create_issue' + dest.parent.mkdir(parents=True) + if kind == 'file': + dest.write_text('preserve') + elif kind == 'directory': + dest.mkdir() + elif kind == 'symlink': + dest.symlink_to(workspace / 'absent') + else: + dest.parent.rmdir() + dest.parent.symlink_to(workspace / 'PyAutoMind', target_is_directory=True) + with pytest.raises(ValueError, match='protected'): + discovery.starter(workspace, True) + assert not (workspace / '.claude/skills/feature').exists() + + +def test_missing_registration_rejected(workspace): + (workspace / 'PyAutoMind/repos.yaml').write_text('repos: {}\n') + with pytest.raises(ValueError, match='registered'): + discovery.starter(workspace, True) + assert not (workspace / '.claude').exists() + + +def test_duplicate_skill_rejected(workspace): + path = workspace / 'PyAutoMind/skills/feature' + path.mkdir() + (path / 'SKILL.md').write_text('---\nname: feature\ndescription: Example\n---\n') + with pytest.raises(ValueError, match='collision'): + discovery.starter(workspace, True) + assert not (workspace / '.claude').exists() + + +def test_workspace_alias_uses_physical_link_base(workspace): + alias_parent = workspace / 'aliases' + alias_parent.mkdir() + alias = alias_parent / 'workspace' + alias.symlink_to(workspace, target_is_directory=True) + assert discovery.starter(alias, True) == 0 + link = workspace / '.claude/skills/feature' + assert link.exists() + assert link.resolve() == workspace / 'PyAutoBrain/skills/feature' + assert discovery.starter(alias, False) == 0 + assert discovery.starter(workspace, False) == 0 + assert discovery.starter(alias, True) == 0