Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion bin/project_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
99 changes: 99 additions & 0 deletions docs/adoption/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
87 changes: 87 additions & 0 deletions tests/test_starter_discovery.py
Original file line number Diff line number Diff line change
@@ -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
Loading