diff --git a/README.md b/README.md index 9d5922d..e083924 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,22 @@ bin/pyauto-brain vitals # read the Heart's readiness verdict bash bin/install.sh # symlink every organ's skills into ~/.claude ``` +Project discovery is generated separately from the registered repository body +map. For example, after adding an assistant skill: + +```bash +bash bin/install.sh --write-project-discovery autolens_assistant +bash bin/install.sh --check-project-discovery autolens_assistant +``` + +Without repository arguments these commands cover registered checkouts with +canonical `skills/` content. Flat assistant skills keep their Claude names and +get namespaced Codex adapters; workspace skills use neutral +`skills//SKILL.md` bodies. Codex discovery descriptions are bounded summaries; +the linked canonical file retains the full instructions. Commit the generated +adapters with the skill. Regeneration checks name collisions across the installed +repository inventory and refuses conflicting user-owned destinations. + The eight organs the Brain coordinates — Brain (reasoning), Mind (intent), Cortex (learning what is true), Memory (knowledge), Heart (health), Hands (release), Nerves (configuration), Gut (shedding) — are defined once in diff --git a/agents/conductors/clone/_clone.py b/agents/conductors/clone/_clone.py index 10d4b21..cda428c 100644 --- a/agents/conductors/clone/_clone.py +++ b/agents/conductors/clone/_clone.py @@ -97,6 +97,8 @@ ".claude/hooks/*", ".claude/settings.json", ".codex/hooks.json", ".gemini/*", ".claude/skills/_*", ".claude/skills/start-new-project*", ".claude/skills/contribute-upstream*", + ".claude/commands/_*", ".claude/commands/start-new-project*", + ".claude/commands/contribute-upstream*", ] # Domain content a newborn regenerates/stubs rather than copies blind, shared @@ -105,6 +107,8 @@ # in a domain assistant, but the near-empty framework scaffold (generic) in the # domain-agnostic base — so each profile places it, not this shared set. _SHARED_DOMAIN = [ + # Generated names/targets belong to the parent; regenerate for the newborn. + ".codex/skills/*", "dataset/*", "README.md", # science framing + the example prompts "hpc/*", @@ -133,6 +137,8 @@ ), "generic": _SHARED_GENERIC, "domain": [ + ".claude/commands/al_*.md", + ".claude/commands/euclid_*.md", ".claude/commands/init-slam.md", "skills/al_*.md", # every al_* skill body ".claude/skills/al_*.md", # ... and their harness mirrors "skills/init-slam.md", ".claude/skills/init-slam.md", # SLAM = lensing @@ -166,6 +172,7 @@ *_SHARED_GENERIC, "skills/af_*.md", # generic inference skills ".claude/skills/af_*.md", # ... and their harness mirrors + ".claude/commands/af_*.md", "wiki/core/*", # statistics/inference reference "wiki/literature/*", # the near-empty literature scaffold ], diff --git a/bin/install.sh b/bin/install.sh index e12976f..ef183df 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -7,7 +7,7 @@ # Claude-only command files remain available as slash commands. Roots that # aren't checked out are simply skipped. # -# Discovery roots (scanned in order): +# User-level install roots (scanned in order): # - admin_jammy/skills/ — vestigial: admin_jammy hosts no skills and is # slated to leave PyAutoLabs/; kept only so an old # checkout still resolves, auto-skipped once gone @@ -276,100 +276,22 @@ workspace_policy() { # ---------- Committed per-tool discovery (web/cloud) ---------- # -# The user-level ~/.claude / ~/.codex symlinks (below) are NOT loaded in cloud/ -# web sessions — only committed repo files travel. So each skill-hosting repo -# also COMMITS a project-level discovery tree — .claude/commands, .claude/skills -# and .codex/skills — as relative symlinks into that repo's own skills/, so the -# slash commands and skills register on web with no user-level install. Per-repo: -# each repo mirrors its OWN skills/ (no cross-repo duplication); a Brain-loaded -# web session gets Brain's verbs, and every other repo's commands register when it -# is checked out. The AGENTS.md command surface (above) still advertises the full -# verb set + hosting organ. Generated + drift-checked — do not hand-edit these -# dirs. Symlinks (single source = skills/), matching the user-level install. -DISCOVERY_REPOS=(PyAutoBrain PyAutoMind PyAutoHeart PyAutoHands autolens_profiling) -DISCOVERY_DIRS=(.claude/commands .claude/skills .codex/skills) - -# Emit the (linktarget) pairs a repo's committed discovery should contain, -# derived from its skills/. Link is relative to the repo root; target relative to -# the link's own directory. Mirrors the user-level install rules: a SKILL.md dir -# becomes a Claude + Codex skill (Codex uses the frontmatter `name:` when valid); -# a .md becomes a Claude slash command. -_discovery_manifest() { - local repo_dir="$1" skills_dir="$1/skills" entry name sk - [ -d "$skills_dir" ] || return 0 - for entry in "$skills_dir"/*/; do - [ -d "$entry" ] || continue - name=$(basename "$entry") - if [ -f "$entry/SKILL.md" ]; then - printf '.claude/skills/%s\t../../skills/%s\n' "$name" "$name" - sk=$(sed -n 's/^name:[[:space:]]*//p' "$entry/SKILL.md" | head -1) - if [ -n "$sk" ] && [[ "$sk" != *[!a-z0-9-]* ]]; then - printf '.codex/skills/%s\t../../skills/%s\n' "$sk" "$name" - fi - fi - if [ -f "$entry/$name.md" ]; then - printf '.claude/commands/%s.md\t../../skills/%s/%s.md\n' "$name" "$name" "$name" - fi - done | sort -} - -# All currently-present managed symlinks in a repo, as (linktarget), sorted. -_discovery_actual() { - local repo_dir="$1" d link - for d in "${DISCOVERY_DIRS[@]}"; do - [ -d "$repo_dir/$d" ] || continue - for link in "$repo_dir/$d"/*; do - [ -L "$link" ] || continue - printf '%s\t%s\n' "${link#"$repo_dir"/}" "$(readlink "$link")" - done - done | sort -} - +# Project discovery is generated from every registered repository in the Mind +# body map. Claude links remain relative to canonical skills/; Codex adapters +# expose flat assistant and workspace skills under stable namespaced names. +# This committed tree is available in cloud/web sessions without user-level setup. +# The Python generator uses the registered body map and preserves user-owned files. write_project_discovery() { - local repo repo_dir link target d - for repo in "${DISCOVERY_REPOS[@]}"; do - repo_dir="$(pyauto_repo_path "$repo" "$PYAUTO_ROOT")" - if [ ! -d "$repo_dir/skills" ]; then echo "skipped (no skills/): $repo_dir"; continue; fi - # Drop our managed symlinks (only symlinks — never real files) so a removed - # or renamed skill does not leave a stale committed link. - for d in "${DISCOVERY_DIRS[@]}"; do - [ -d "$repo_dir/$d" ] && find "$repo_dir/$d" -maxdepth 1 -type l -delete 2>/dev/null || true - done - while IFS=$'\t' read -r link target; do - [ -n "$link" ] || continue - mkdir -p "$repo_dir/$(dirname "$link")" - ln -sf "$target" "$repo_dir/$link" - done < <(_discovery_manifest "$repo_dir") - echo "wrote discovery: $repo (.claude/{commands,skills}, .codex/skills)" - done + python3 "$SCRIPT_DIR/project_discovery.py" write --root "$PYAUTO_ROOT" "$@" } - -# Optional trailing repo names narrow the check to those repos (the pytest leg -# checks PyAutoBrain alone, so a local run is not reddened by a sibling's drift). check_project_discovery() { - local repo repo_dir want got drift=0 - local repos=("${DISCOVERY_REPOS[@]}") - [ "$#" -gt 0 ] && repos=("$@") - for repo in "${repos[@]}"; do - repo_dir="$(pyauto_repo_path "$repo" "$PYAUTO_ROOT")" - [ -d "$repo_dir/skills" ] || continue - want="$(_discovery_manifest "$repo_dir")" - got="$(_discovery_actual "$repo_dir")" - if [ "$want" = "$got" ]; then - echo "OK: $repo" - else - echo "DRIFT: $repo — run: bash PyAutoBrain/bin/install.sh --write-project-discovery" - diff <(printf '%s\n' "$want") <(printf '%s\n' "$got") | sed 's/^/ /' - drift=1 - fi - done - return "$drift" + python3 "$SCRIPT_DIR/project_discovery.py" check --root "$PYAUTO_ROOT" "$@" } case "${1:-}" in --write-agents-surface) write_agents_surface; exit 0 ;; --check-agents-surface) check_agents_surface; exit $? ;; - --write-project-discovery) write_project_discovery; exit 0 ;; + --write-project-discovery) shift; write_project_discovery "$@"; exit $? ;; --check-project-discovery) shift; check_project_discovery "$@"; exit $? ;; --write-workspace-policy) workspace_policy write; exit $? ;; --check-workspace-policy) workspace_policy check; exit $? ;; diff --git a/bin/project_discovery.py b/bin/project_discovery.py new file mode 100644 index 0000000..8c6db39 --- /dev/null +++ b/bin/project_discovery.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Generate checked-in Claude and Codex discovery from registered repo skills.""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'agents')) +from _repo_paths import bootstrap_repo_path, manifest_paths, repo_path + +MARKER = '' +SURFACES = ('.claude/commands', '.claude/skills', '.codex/skills') +VALID = re.compile(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z') + + +def slug(value: str) -> str: + return re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-') + + +def frontmatter(path: Path) -> dict[str, str]: + lines = path.read_text().splitlines() + if not lines or lines[0] != '---': + return {} + fields = {} + for line in lines[1:]: + if line == '---': + return fields + if ':' in line and not line.startswith(' '): + key, value = line.split(':', 1) + fields[key] = value.strip().strip('"\'') + return {} + + +def discovery_description(description: str) -> str: + # Codex caps descriptions at 1024 characters and disallows angle brackets. + # Keep a readable prefix; the canonical body retains the full text. + value = re.sub(r'<([^<>]+)>', r'(\1)', description) + value = value.replace('<', 'less than').replace('>', 'greater than') + value = ' '.join(value.split()) + if len(value) <= 1024: + return value + sentences = re.split(r'(?<=[.!?])\s+', value) + chosen = '' + for sentence in sentences: + candidate = f'{chosen} {sentence}'.strip() + if len(candidate) > 1024: + break + chosen = candidate + if chosen: + return chosen + return value[:1024].rsplit(' ', 1)[0].rstrip(' ,;:') + + +def adapter(name: str, description: str, link: str) -> str: + if len(name) > 64 or not VALID.fullmatch(name): + raise ValueError(f'invalid Codex skill name: {name}') + description = discovery_description(description) + if not description: + raise ValueError(f'{name}: missing/invalid description') + return (f'---\nname: {name}\ndescription: {json.dumps(description, ensure_ascii=False)}\n---\n' + f'{MARKER}\n\nRead and follow the canonical skill: [SKILL]({link}).\n') + + +def expected(repo: str, path: Path, category: str = "") -> dict[str, tuple[str, str]]: + """Relative path -> (symlink, target) or (file, contents).""" + skills = path / 'skills' + result = {} + if not skills.is_dir(): + return result + prefix = slug(repo) + def add(key: str, value: tuple[str, str]) -> None: + if key in result: + raise ValueError(f'{repo}: duplicate discovery path: {key}') + result[key] = value + for entry in sorted(skills.iterdir()): + if entry.is_dir() and (entry / 'SKILL.md').is_file(): + name = entry.name + add(f'.claude/skills/{name}', ('symlink', f'../../skills/{name}')) + body = entry / 'SKILL.md' + if category in {'organ', 'project'}: + codex_name = frontmatter(body).get('name', '') + if len(codex_name) <= 64 and VALID.fullmatch(codex_name): + add(f'.codex/skills/{codex_name}', ('symlink', f'../../skills/{name}')) + else: + codex_name = f'{prefix}-{slug(name)}' + meta = frontmatter(body) + add(f'.codex/skills/{codex_name}/SKILL.md', ('file', adapter( + codex_name, meta.get('description', ''), f'../../../skills/{name}/SKILL.md'))) + command = entry / f'{name}.md' + if command.is_file(): + add(f'.claude/commands/{name}.md', ('symlink', f'../../skills/{name}/{name}.md')) + elif entry.is_file() and entry.suffix == '.md' and entry.name != 'README.md': + meta = frontmatter(entry) + if not meta and not entry.name.startswith('_'): + continue # supporting prose is not a skill + # Existing flat Claude skill links include private helpers. + add(f'.claude/skills/{entry.name}', ('symlink', f'../../skills/{entry.name}')) + if entry.name.startswith('_'): + continue + if not meta.get('name') or not meta.get('description'): + raise ValueError(f'{entry}: public flat skill requires name and description') + name = entry.stem + add(f'.claude/commands/{name}.md', ('symlink', f'../../skills/{entry.name}')) + codex_name = f'{prefix}-{slug(meta["name"])}' + add(f'.codex/skills/{codex_name}/SKILL.md', ('file', adapter( + codex_name, meta['description'], f'../../../skills/{entry.name}'))) + return result + + +def managed_link(repo: Path, surface: str, name: str, target: str) -> bool: + # Only the generator's canonical relative links are owned. A user's link + # into another tree (or to another skill name) must survive regeneration. + prefix = '../../skills/' + if not target.startswith(prefix): + return False + parts = target[len(prefix):].split('/') + if any(part in ('', '.', '..') for part in parts): + return False + if surface == '.claude/skills': + return len(parts) == 1 and parts[0] == name + if surface == '.claude/commands': + return (len(parts) == 1 and parts[0] == name + or len(parts) == 2 and parts[1] == name and parts[0] == name[:-3]) + if surface == '.codex/skills' and len(parts) == 1: + body = repo / 'skills' / parts[0] / 'SKILL.md' + return body.is_file() and frontmatter(body).get('name') == name + return False + + +def actual(path: Path) -> dict[str, tuple[str, str]]: + found = {} + for surface in SURFACES: + base = path / surface + if base.is_symlink() or base.parent.is_symlink(): + continue + if not base.is_dir(): + continue + for child in base.iterdir(): + key = str(child.relative_to(path)) + if child.is_symlink(): + target = os.readlink(child) + if managed_link(path, surface, child.name, target): + found[key] = ('symlink', target) + elif (surface == '.codex/skills' and target.startswith('../../skills/') + and len(target[len('../../skills/'):].split('/')) == 1 + and not child.exists()): + found[key] = ('dangling', target) + else: + found[key] = ('protected', '') + elif child.is_dir() and surface == '.codex/skills': + body = child / 'SKILL.md' + if body.is_file() and MARKER in body.read_text(): + found[str(body.relative_to(path))] = ('file', body.read_text()) + else: + found[key] = ('protected', '') + else: + found[key] = ('protected', '') + return found + + +def manifest_categories(root: Path) -> dict[str, str]: + """Read category metadata from the same body map used for repo placement.""" + manifest = bootstrap_repo_path(root, 'PyAutoMind') / 'repos.yaml' + if not manifest.is_file(): + return {} + categories = {} + current = None + in_repos = False + for line in manifest.read_text().splitlines(): + if line.strip() == 'repos:': + in_repos = True + continue + if not in_repos: + continue + if line and not line.startswith(' '): + break + match = re.fullmatch(r' ([A-Za-z0-9_.-]+):\s*(?:#.*)?', line) + if match: + current = match[1] + elif current and re.match(r' category:\s*', line): + categories[current] = line.split(':', 1)[1].split('#', 1)[0].strip().strip('"\'') + return categories + + +def run(root: Path, names: list[str], write: bool) -> int: + registry = manifest_paths(root) + categories = manifest_categories(root) + selected = names or list(registry) + if names: + unknown = set(names) - set(registry) + if unknown: + raise ValueError(f'unregistered repository: {", ".join(sorted(unknown))}') + plans = {} + owners = {} + for name in registry: + path = repo_path(root, name) + if not path.is_dir() or not (path / 'skills').is_dir(): + continue + wanted = expected(name, path, categories.get(name, "")) + for rel in wanted: + if rel.startswith('.codex/skills/'): + codex_name = rel.split('/')[2] + prior = owners.setdefault(codex_name, name) + if prior != name: + raise ValueError(f'Codex skill collision {codex_name}: {prior}, {name}') + if name in selected: + plans[name] = (path, wanted) + # Refuse the complete write if any destination is user-owned. + def protected_conflicts(path: Path, wanted: dict[str, tuple[str, str]]) -> list[str]: + present = actual(path) + conflicts = [] + for key in wanted: + if key in present and present[key][0] == 'protected': + conflicts.append(key) + parent = Path(key).parent + while str(parent) != '.': + existing = path / parent + if existing.is_symlink() and str(parent) not in present: + conflicts.append(str(parent)) + break + if existing.exists() and not existing.is_dir(): + conflicts.append(str(parent)) + break + if str(parent) in present and present[str(parent)][0] == 'protected': + conflicts.append(str(parent)) + break + parent = parent.parent + for surface in SURFACES: + if not any(key.startswith(surface + '/') for key in wanted): + continue + base = path / surface + for root in (base.parent, base): + if root.is_symlink() or (root.exists() and not root.is_dir()): + conflicts.append(str(root.relative_to(path))) + conflicts.extend(key for key, value in present.items() if value[0] == 'dangling') + return sorted(set(conflicts)) + conflicts_by_repo = {name: protected_conflicts(path, wanted) + for name, (path, wanted) in plans.items()} + has_conflict = any(conflicts_by_repo.values()) + drift = False + for name, (path, wanted) in plans.items(): + present = actual(path) + managed = {key: value for key, value in present.items() if value[0] == 'symlink' or value[0] == 'file'} + conflicts = conflicts_by_repo[name] + missing = sorted(set(wanted) - set(managed)) + stale = sorted(set(managed) - set(wanted)) + changed = sorted(key for key in set(wanted) & set(managed) if wanted[key] != managed[key]) + if conflicts or missing or stale or changed: + drift = True + print(f'{"CONFLICT" if conflicts else "DRIFT"}: {name}: ' + ', '.join( + f'{kind} {items}' for kind, items in [('protected', conflicts), ('missing', missing), ('stale', stale), ('changed', changed)] if items)) + else: + print(f'OK: {name}') + if not write or has_conflict: + continue + for rel in stale + changed: + item = path / rel + item.unlink() + if item.parent != path and not any(item.parent.iterdir()): + item.parent.rmdir() + for rel in missing + changed: + kind, data = wanted[rel] + item = path / rel + item.parent.mkdir(parents=True, exist_ok=True) + if kind == 'symlink': + item.symlink_to(data) + else: + item.write_text(data) + return 2 if has_conflict and write else int(drift and not write) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=('write', 'check')) + parser.add_argument('repos', nargs='*') + parser.add_argument('--root', type=Path, default=Path(os.environ.get('PYAUTO_ROOT', '.'))) + args = parser.parse_args() + try: + return run(args.root, args.repos, args.mode == 'write') + except (ValueError, OSError) as error: + print(f'project discovery: {error}', file=sys.stderr) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_clone_conductor.py b/tests/test_clone_conductor.py index 3f995ca..2d0eea8 100644 --- a/tests/test_clone_conductor.py +++ b/tests/test_clone_conductor.py @@ -64,6 +64,8 @@ def test_unknown_reference_fails_cleanly(): # the domain-agnostic base keeps af_* inference skills as GENERIC ("skills/af_compose_model.md", "generic"), (".claude/skills/af_wrap_likelihood.md", "generic"), + (".claude/commands/af_wrap_likelihood.md", "generic"), + (".codex/skills/example-assistant-af-wrap-likelihood/SKILL.md", "domain"), # ...and wiki/core teaches statistics here, so it too is GENERIC ("wiki/core/index.md", "generic"), ("wiki/core/concepts/priors.md", "generic"), @@ -92,6 +94,11 @@ def test_autofit_profile_classification(path, expected): # domain has, if any. ("skills/euclid_model_lens.md", "domain"), (".claude/skills/euclid_hpc_runs.md", "domain"), + (".claude/commands/al_plot_tracer.md", "domain"), + (".claude/commands/euclid_hpc_runs.md", "domain"), + (".claude/commands/init-slam.md", "domain"), + (".claude/commands/contribute-upstream.md", "generic"), + (".codex/skills/example-assistant-al-plot-tracer/SKILL.md", "domain"), ("wiki/euclid/index.md", "domain"), ("wiki/euclid/entities/vis.md", "domain"), ("wiki/euclid/bibliography/euclid.bib", "domain"), @@ -130,3 +137,16 @@ def test_wiki_core_and_skills_flip_between_references(): if __name__ == "__main__": sys.exit(pytest.main([__file__, "-q"])) + + +@pytest.mark.parametrize("reference", ["autofit_assistant", "autolens_assistant"]) +def test_generated_codex_wrappers_are_excluded_from_seed_copy_sets(monkeypatch, reference): + # apply_seed passes generic + mixed to the copying primitive. A generated + # wrapper may refer to a domain skill omitted from that newborn; copying it + # would leave a discoverable but broken skill even after name substitutions. + wrapper = ".codex/skills/example-assistant-science-fit/SKILL.md" + monkeypatch.setattr(clone, "tracked_files", lambda root: [wrapper, "AGENTS.md"]) + sets = clone.partition(Path("unused"), clone.REFERENCE_PROFILES[reference]) + assert wrapper in sets["domain"] + assert wrapper not in sets["generic"] + sets["mixed"] + assert not sets["unclassified"] diff --git a/tests/test_skill_install.py b/tests/test_skill_install.py index 7b4205c..8954fc6 100644 --- a/tests/test_skill_install.py +++ b/tests/test_skill_install.py @@ -289,3 +289,230 @@ def test_command_surface_covers_every_public_agent(): name for name in sorted(agents) if f"`bin/pyauto-brain {name}`" not in surface ] assert missing == [] + + +def test_registered_discovery_adapters_and_scoped_write(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text( + 'repos:\n PyAutoBrain:\n path: PyAutoBrain\n' + ' example_assistant:\n path: lens/example_assistant\n category: assistant\n' + ' example_workspace:\n path: lens/example_workspace\n category: workspace\n') + (root / 'PyAutoBrain').symlink_to(BRAIN_HOME, target_is_directory=True) + assistant = root / 'lens' / 'example_assistant' + assistant.mkdir(parents=True) + skills = assistant / 'skills' + skills.mkdir() + (skills / 'al_plot.md').write_text('---\nname: al_plot\ndescription: ' + 'Plot a with A > B. ' * 80 + '\n---\n\nBody.\n') + (skills / '_helper.md').write_text('# Helper\n') + (skills / 'README.md').write_text('# Readme\n') + workspace = root / 'lens' / 'example_workspace' + neutral = workspace / 'skills' / 'open_result' + neutral.mkdir(parents=True) + (neutral / 'SKILL.md').write_text('---\nname: open_result\ndescription: Open a result.\n---\n') + env = os.environ | {'PYAUTO_ROOT': str(root)} + def call(mode, *repos): + return subprocess.run(['bash', str(INSTALLER), mode, *repos], env=env, + capture_output=True, text=True) + assert call('--check-project-discovery', 'example_assistant').returncode == 1 + assert call('--write-project-discovery', 'example_assistant').returncode == 0 + assert not (workspace / '.codex').exists() + assert (assistant / '.claude/commands/al_plot.md').is_symlink() + adapter = assistant / '.codex/skills/example-assistant-al-plot/SKILL.md' + assert adapter.is_file() + assert 'name: example-assistant-al-plot' in adapter.read_text() + desc_line = next(line for line in adapter.read_text().splitlines() if line.startswith('description: ')) + assert len(desc_line) < 1050 + assert '<' not in desc_line and '>' not in desc_line + assert (adapter.parent / '../../../skills/al_plot.md').resolve() == skills / 'al_plot.md' + assert not (assistant / '.claude/commands/_helper.md').exists() + assert (assistant / '.claude/skills/_helper.md').is_symlink() + assert call('--write-project-discovery', 'example_workspace').returncode == 0 + assert (workspace / '.claude/skills/open_result').is_symlink() + assert (workspace / '.codex/skills/example-workspace-open-result/SKILL.md').is_file() + assert call('--check-project-discovery', 'example_assistant', 'example_workspace').returncode == 0 + assert call('--write-project-discovery', 'example_assistant', 'example_workspace').returncode == 0 + + +def test_discovery_detects_drift_and_preserves_user_file(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text('repos:\n sample_assistant:\n path: sample_assistant\n') + repo = root / 'sample_assistant' + skills = repo / 'skills' + skills.mkdir(parents=True) + (skills / 'run.md').write_text('---\nname: run\ndescription: Run.\n---\n') + protected = repo / '.claude/commands/run.md' + protected.parent.mkdir(parents=True) + protected.write_text('user\n') + env = os.environ | {'PYAUTO_ROOT': str(root)} + write = subprocess.run(['bash', str(INSTALLER), '--write-project-discovery', 'sample_assistant'], + env=env, capture_output=True, text=True) + assert protected.read_text() == 'user\n' + assert write.returncode == 2 + assert 'CONFLICT' in write.stdout + assert not (repo / '.codex').exists() + assert subprocess.run(['bash', str(INSTALLER), '--check-project-discovery', 'sample_assistant'], + env=env, capture_output=True).returncode == 1 + protected.unlink() + subprocess.run(['bash', str(INSTALLER), '--write-project-discovery', 'sample_assistant'], env=env, check=True) + (skills / 'run.md').unlink() + assert subprocess.run(['bash', str(INSTALLER), '--check-project-discovery', 'sample_assistant'], + env=env, capture_output=True).returncode == 1 + subprocess.run(['bash', str(INSTALLER), '--write-project-discovery', 'sample_assistant'], env=env, check=True) + assert not (repo / '.codex/skills/sample-assistant-run').exists() + + +def test_scoped_discovery_rejects_cross_repo_codex_collision(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text( + 'repos:\n foo_bar:\n path: foo_bar\n foo-bar:\n path: foo-bar\n') + for name in ('foo_bar', 'foo-bar'): + skill = root / name / 'skills' / 'run.md' + skill.parent.mkdir(parents=True) + skill.write_text('---\nname: run\ndescription: Run.\n---\n') + result = subprocess.run( + ['bash', str(INSTALLER), '--write-project-discovery', 'foo_bar'], + env=os.environ | {'PYAUTO_ROOT': str(root)}, capture_output=True, text=True) + assert result.returncode == 2 + assert 'Codex skill collision foo-bar-run' in result.stderr + assert not (root / 'foo_bar' / '.codex').exists() + + +def test_discovery_preserves_unrelated_user_symlinks_and_rejects_target_conflict(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text('repos:\n sample_assistant:\n path: sample_assistant\n') + repo = root / 'sample_assistant' + skills = repo / 'skills' + skills.mkdir(parents=True) + (skills / 'run.md').write_text('---\nname: run\ndescription: Run.\n---\n') + user = repo / '.claude/skills/custom.md' + user.parent.mkdir(parents=True) + user.symlink_to('../../notes/custom.md') + env = os.environ | {'PYAUTO_ROOT': str(root)} + def call(mode): + return subprocess.run(['bash', str(INSTALLER), mode, 'sample_assistant'], + env=env, capture_output=True, text=True) + assert call('--write-project-discovery').returncode == 0 + assert user.is_symlink() and os.readlink(user) == '../../notes/custom.md' + assert call('--check-project-discovery').returncode == 0 + expected_link = repo / '.claude/skills/run.md' + expected_link.unlink() + expected_link.symlink_to('../../notes/run.md') + original_adapter = (repo / '.codex/skills/sample-assistant-run/SKILL.md').read_text() + assert call('--write-project-discovery').returncode == 2 + assert os.readlink(expected_link) == '../../notes/run.md' + assert (repo / '.codex/skills/sample-assistant-run/SKILL.md').read_text() == original_adapter + + +def test_discovery_detects_same_repo_normalized_name_collision(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text('repos:\n sample_assistant:\n path: sample_assistant\n') + skills = root / 'sample_assistant' / 'skills' + skills.mkdir(parents=True) + (skills / 'first.md').write_text('---\nname: same_name\ndescription: First.\n---\n') + (skills / 'second.md').write_text('---\nname: same-name\ndescription: Second.\n---\n') + result = subprocess.run(['bash', str(INSTALLER), '--write-project-discovery', 'sample_assistant'], + env=os.environ | {'PYAUTO_ROOT': str(root)}, capture_output=True, text=True) + assert result.returncode == 2 + assert 'duplicate discovery path' in result.stderr + assert not (root / 'sample_assistant' / '.codex').exists() + + +def test_discovery_repairs_broken_managed_link(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text('repos:\n sample_assistant:\n path: sample_assistant\n') + skills = root / 'sample_assistant' / 'skills' + skills.mkdir(parents=True) + (skills / 'run.md').write_text('---\nname: run\ndescription: Run.\n---\n') + env = os.environ | {'PYAUTO_ROOT': str(root)} + def call(mode): + return subprocess.run(['bash', str(INSTALLER), mode, 'sample_assistant'], + env=env, capture_output=True, text=True) + assert call('--write-project-discovery').returncode == 0 + link = root / 'sample_assistant' / '.claude/skills/run.md' + (skills / 'run.md').unlink() + assert link.is_symlink() and not link.exists() + assert call('--check-project-discovery').returncode == 1 + assert call('--write-project-discovery').returncode == 0 + assert not link.is_symlink() + + +def test_removed_organ_skill_reports_dangling_codex_link_without_deleting(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text('repos:\n PyAutoHeart:\n path: PyAutoHeart\n category: organ\n') + repo = root / 'PyAutoHeart' + skill = repo / 'skills' / 'pulse' + skill.mkdir(parents=True) + (skill / 'SKILL.md').write_text('---\nname: pulse\ndescription: Read pulse.\n---\n') + env = os.environ | {'PYAUTO_ROOT': str(root)} + def call(mode): + return subprocess.run(['bash', str(INSTALLER), mode, 'PyAutoHeart'], + env=env, capture_output=True, text=True) + assert call('--write-project-discovery').returncode == 0 + codex_link = repo / '.codex/skills/pulse' + (skill / 'SKILL.md').unlink() + skill.rmdir() + assert codex_link.is_symlink() and not codex_link.exists() + assert call('--check-project-discovery').returncode == 1 + write = call('--write-project-discovery') + assert write.returncode == 2 + assert 'pulse' in write.stdout + assert codex_link.is_symlink() + + +def test_discovery_refuses_symlinked_surface_even_with_no_expected_links(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text('repos:\n sample_assistant:\n path: sample_assistant\n') + repo = root / 'sample_assistant' + (repo / 'skills').mkdir(parents=True) + outside = tmp_path / 'outside' + outside.mkdir() + user_link = outside / 'custom.md' + user_link.symlink_to('../../skills/custom.md') + (repo / '.claude').mkdir() + (repo / '.claude/commands').symlink_to(outside, target_is_directory=True) + env = os.environ | {'PYAUTO_ROOT': str(root)} + for mode in ('--check-project-discovery', '--write-project-discovery'): + result = subprocess.run(['bash', str(INSTALLER), mode, 'sample_assistant'], + env=env, capture_output=True, text=True) + assert result.returncode == 0 + assert user_link.is_symlink() + (repo / 'skills/run.md').write_text('---\nname: run\ndescription: Run.\n---\n') + result = subprocess.run(['bash', str(INSTALLER), '--write-project-discovery', 'sample_assistant'], + env=env, capture_output=True, text=True) + assert result.returncode == 2 + assert '.claude/commands' in result.stdout + assert user_link.is_symlink() + + +def test_project_category_keeps_existing_directory_skill_name(tmp_path): + root = tmp_path / 'PyAutoLabs' + mind = root / 'PyAutoMind' + mind.mkdir(parents=True) + (mind / 'repos.yaml').write_text( + 'repos:\n analysis_project:\n path: analysis_project\n category: project\n') + skill = root / 'analysis_project' / 'skills' / 'profile_likelihood' + skill.mkdir(parents=True) + (skill / 'SKILL.md').write_text( + '---\nname: profile-likelihood\ndescription: Profile a likelihood.\n---\n') + result = subprocess.run( + ['bash', str(INSTALLER), '--write-project-discovery', 'analysis_project'], + env=os.environ | {'PYAUTO_ROOT': str(root)}, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + assert (root / 'analysis_project/.codex/skills/profile-likelihood').is_symlink() + assert not (root / 'analysis_project/.codex/skills/analysis-project-profile-likelihood').exists()