From 44390d8d320f952c16b4bae62b910d4a037de43d Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:27:00 +0100 Subject: [PATCH 1/9] feat: add Codex skill discovery with canonical shared instructions --- bin/install.sh | 96 ++--------------- bin/project_discovery.py | 207 ++++++++++++++++++++++++++++++++++++ tests/test_skill_install.py | 89 ++++++++++++++++ 3 files changed, 305 insertions(+), 87 deletions(-) create mode 100644 bin/project_discovery.py diff --git a/bin/install.sh b/bin/install.sh index e12976f3..ef183dff 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 00000000..f2e7194b --- /dev/null +++ b/bin/project_discovery.py @@ -0,0 +1,207 @@ +#!/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 manifest_paths, repo_path + +MARKER = '' +ORGAN_NAMES = {'PyAutoBrain', 'PyAutoMind', 'PyAutoHeart', 'PyAutoHands', 'PyAutoMemory', 'autolens_profiling'} +SURFACES = ('.claude/commands', '.claude/skills', '.codex/skills') +VALID = re.compile(r'[a-z][a-z0-9-]{0,63}\Z') + + +def slug(value: str) -> str: + return value.lower().replace('_', '-') + + +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 adapter(name: str, description: str, link: str) -> str: + if not VALID.fullmatch(name): + raise ValueError(f'invalid Codex skill name: {name}') + if not description or '\n' in 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) -> 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 repo in ORGAN_NAMES: + codex_name = frontmatter(body).get('name', '') + if 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 actual(path: Path) -> dict[str, tuple[str, str]]: + found = {} + for surface in SURFACES: + base = path / surface + if not base.is_dir(): + continue + for child in base.iterdir(): + key = str(child.relative_to(path)) + if child.is_symlink(): + found[key] = ('symlink', os.readlink(child)) + 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 run(root: Path, names: list[str], write: bool) -> int: + registry = manifest_paths(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) + 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 + 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] != 'protected'} + conflicts = sorted(key for key in wanted if key in present and present[key][0] == 'protected') + 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_skill_install.py b/tests/test_skill_install.py index 7b4205c7..6b227548 100644 --- a/tests/test_skill_install.py +++ b/tests/test_skill_install.py @@ -289,3 +289,92 @@ 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' + ' autolens_assistant:\n path: lens/autolens_assistant\n' + ' autolens_workspace:\n path: lens/autolens_workspace\n') + (root / 'PyAutoBrain').symlink_to(BRAIN_HOME, target_is_directory=True) + assistant = root / 'lens' / 'autolens_assistant' + assistant.mkdir(parents=True) + skills = assistant / 'skills' + skills.mkdir() + (skills / 'al_plot.md').write_text('---\nname: al_plot\ndescription: ' + 'Plot a lens. ' * 50 + '\n---\n\nBody.\n') + (skills / '_helper.md').write_text('# Helper\n') + (skills / 'README.md').write_text('# Readme\n') + workspace = root / 'lens' / 'autolens_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', 'autolens_assistant').returncode == 1 + assert call('--write-project-discovery', 'autolens_assistant').returncode == 0 + assert not (workspace / '.codex').exists() + assert (assistant / '.claude/commands/al_plot.md').is_symlink() + adapter = assistant / '.codex/skills/autolens-assistant-al-plot/SKILL.md' + assert adapter.is_file() + assert 'name: autolens-assistant-al-plot' in adapter.read_text() + 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', 'autolens_workspace').returncode == 0 + assert (workspace / '.claude/skills/open_result').is_symlink() + assert (workspace / '.codex/skills/autolens-workspace-open-result/SKILL.md').is_file() + assert call('--check-project-discovery', 'autolens_assistant', 'autolens_workspace').returncode == 0 + assert call('--write-project-discovery', 'autolens_assistant', 'autolens_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() From 0b5c47b2bb048b837f7f8118f135d7f7d1506b4c Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:34:22 +0100 Subject: [PATCH 2/9] fix: validate Codex metadata and preserve user discovery files --- README.md | 16 +++++ bin/project_discovery.py | 75 +++++++++++++++++++--- tests/test_skill_install.py | 122 +++++++++++++++++++++++++++++++++++- 3 files changed, 204 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9d5922dd..e0839242 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/bin/project_discovery.py b/bin/project_discovery.py index f2e7194b..6e8a4332 100644 --- a/bin/project_discovery.py +++ b/bin/project_discovery.py @@ -15,11 +15,11 @@ MARKER = '' ORGAN_NAMES = {'PyAutoBrain', 'PyAutoMind', 'PyAutoHeart', 'PyAutoHands', 'PyAutoMemory', 'autolens_profiling'} SURFACES = ('.claude/commands', '.claude/skills', '.codex/skills') -VALID = re.compile(r'[a-z][a-z0-9-]{0,63}\Z') +VALID = re.compile(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z') def slug(value: str) -> str: - return value.lower().replace('_', '-') + return re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-') def frontmatter(path: Path) -> dict[str, str]: @@ -36,10 +36,31 @@ def frontmatter(path: Path) -> dict[str, str]: 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 not VALID.fullmatch(name): + if len(name) > 64 or not VALID.fullmatch(name): raise ValueError(f'invalid Codex skill name: {name}') - if not description or '\n' in description: + 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') @@ -63,7 +84,7 @@ def add(key: str, value: tuple[str, str]) -> None: body = entry / 'SKILL.md' if repo in ORGAN_NAMES: codex_name = frontmatter(body).get('name', '') - if VALID.fullmatch(codex_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)}' @@ -91,16 +112,46 @@ def add(key: str, value: tuple[str, str]) -> None: 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(): - found[key] = ('symlink', os.readlink(child)) + 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(): @@ -154,6 +205,14 @@ def protected_conflicts(path: Path, wanted: dict[str, tuple[str, str]]) -> list[ 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()} @@ -161,8 +220,8 @@ def protected_conflicts(path: Path, wanted: dict[str, tuple[str, str]]) -> list[ drift = False for name, (path, wanted) in plans.items(): present = actual(path) - managed = {key: value for key, value in present.items() if value[0] != 'protected'} - conflicts = sorted(key for key in wanted if key in present and present[key][0] == 'protected') + 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]) diff --git a/tests/test_skill_install.py b/tests/test_skill_install.py index 6b227548..460c6c3c 100644 --- a/tests/test_skill_install.py +++ b/tests/test_skill_install.py @@ -304,7 +304,7 @@ def test_registered_discovery_adapters_and_scoped_write(tmp_path): assistant.mkdir(parents=True) skills = assistant / 'skills' skills.mkdir() - (skills / 'al_plot.md').write_text('---\nname: al_plot\ndescription: ' + 'Plot a lens. ' * 50 + '\n---\n\nBody.\n') + (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' / 'autolens_workspace' @@ -322,6 +322,9 @@ def call(mode, *repos): adapter = assistant / '.codex/skills/autolens-assistant-al-plot/SKILL.md' assert adapter.is_file() assert 'name: autolens-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() @@ -378,3 +381,120 @@ def test_scoped_discovery_rejects_cross_repo_codex_collision(tmp_path): 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') + 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() From bfa660ce30735b2d96bc64bd8d6d0be7868f2bc0 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:47:50 +0100 Subject: [PATCH 3/9] maintenance: finish provider-neutral instructions and agent metadata --- README.md | 6 +- agents/faculties/memory/_memory.py | 3 +- docs/agent_harness_smoke.md | 94 +++++++++++++++++++++++++++ docs/index.md | 1 + skills/prm/prm.md | 9 ++- skills/prm/reference.md | 2 +- skills/start_dev/reference.md | 7 +- skills/start_library/start_library.md | 7 +- skills/start_workspace/reference.md | 7 +- skills/wake_up/wake_up.md | 2 +- tests/test_memory_surfaces.py | 12 ++++ 11 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 docs/agent_harness_smoke.md diff --git a/README.md b/README.md index e0839242..8b01dc27 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ See the **[PyAutoBrain Dashboard](https://pyautolabs.github.io/PyAutoBrain/)** for the organism's morning and general starting point: what ran overnight, the Heart's readiness headline, who in the community is waiting on a reply, what to resume, and the upkeep doors — each actionable row with a one-tap 📋 -copy-for-Claude command. Regenerated each morning; the local sync/clean leg is +copyable agent command. Regenerated each morning; the local sync/clean leg is one terminal command, `bash bin/morning.sh` — or schedule it overnight on the dev box with `bash bin/morning_timer.sh install`, so the board is already fresh when you wake. ## How PyAutoBrain works -You drive it in plain English, through short slash commands in a Claude Code +You drive it in plain English, through discoverable skills in Codex or slash commands in Claude Code chat: `/intake` to file an idea, `/start_dev` to begin a task, `/health` for a check-up — or just `/route ` and the Brain picks the right door. The generated table in [AGENTS.md](AGENTS.md) is the roster — every @@ -60,7 +60,7 @@ CLI, which runs straight from this checkout (no pip install): bin/pyauto-brain help # list every conductor and faculty bin/pyauto-brain route "fix the failing lens smoke test" # plain English in bin/pyauto-brain vitals # read the Heart's readiness verdict -bash bin/install.sh # symlink every organ's skills into ~/.claude +bash bin/install.sh # install shared skills for Claude Code and Codex ``` Project discovery is generated separately from the registered repository body diff --git a/agents/faculties/memory/_memory.py b/agents/faculties/memory/_memory.py index 8488a656..38e71556 100755 --- a/agents/faculties/memory/_memory.py +++ b/agents/faculties/memory/_memory.py @@ -41,8 +41,9 @@ def surfaces(memory: Path | None, assistant: Path | None, mind: Path | None): # queue, the bibliography workflow, and the wiki schema. Without # these the front door PyAutoMemory/AGENTS.md points every agent at # ("index first") was invisible to this faculty (PyAutoBrain#239). + schema = "wiki/AGENTS.md" if (memory / "wiki/AGENTS.md").is_file() else "wiki/CLAUDE.md" for rel in ("index.md", "reading-queue.md", - "bibliography/README.md", "wiki/CLAUDE.md"): + "bibliography/README.md", schema): f = memory / rel if f.is_file(): yield "PyAutoMemory/root", memory, f diff --git a/docs/agent_harness_smoke.md b/docs/agent_harness_smoke.md new file mode 100644 index 00000000..28bd5de6 --- /dev/null +++ b/docs/agent_harness_smoke.md @@ -0,0 +1,94 @@ +# Agent harness support and bounded Codex smoke + +Recorded 2026-09-19 for PyAutoBrain issues #386 (discovery) and #400 +(metadata and documentation). This is a bounded local check, not certification +of every scientific workflow or every agent product. + +## Shared instructions and adapters + +`AGENTS.md` holds shared policy, including nested folder instructions. +`CLAUDE.md` imports the sibling file. Public assistant skills retain their flat +canonical Markdown; workspace skills live at `skills//SKILL.md`. +Generated Claude links and Codex adapters refer to these same bodies. Canonical +instructions retain the full skill description when the discovery description +needs shortening to satisfy Codex metadata limits. + +The [official skill documentation](https://learn.chatgpt.com/docs/build-skills) +describes `.agents/skills` as the repository discovery location. This workspace +retains its existing `.codex/skills` adapters: the installed CLI version below +actually discovered them. This evidence is specific to that version; do not infer +support for every older/newer CLI, IDE, or cloud deployment from it. + +## Real harness check + +Environment: local Linux, `codex-cli 0.155.1`. Started `codex app-server --stdio`, +completed `initialize` / `initialized`, then sent this request with the seven +actual repository working directories and waited for its response: + +```json +{"id":2,"method":"skills/list","params":{"cwds":[""],"forceReload":true}} +``` + +The four assistants used the phase-4 worktrees; the three workspaces used the +phase-3 worktrees. Both contain the phase-3 generated discovery artifacts. +Only entries whose returned path is inside the requested repository were counted; +user-level and system skills were excluded. + +| Repository | Expected adapters | Discovered | Discovery errors | +|---|---:|---:|---:| +| autofit_assistant | 20 | 20 | 0 | +| autogalaxy_assistant | 25 | 25 | 0 | +| autolens_assistant | 44 | 44 | 0 | +| autocti_assistant | 12 | 12 | 0 | +| autofit_workspace | 1 | 1 | 0 | +| autogalaxy_workspace | 2 | 2 | 0 | +| autolens_workspace | 1 | 1 | 0 | + +The process was terminated after the response. No model turn, fit, deployment, +or hook-trust mutation was requested. Separately, this Codex development session +read the shared workflow instructions and ran `bin/pyauto-brain help` successfully. + +## Adapter and guard checks + +- All 105 adapters passed the installed skill-creator `quick_validate.py`. +- All eight phase-3 repository discovery checks passed; canonical references + resolved and existing Claude links remained usable. +- The four workspace skill bodies moved byte-for-byte to their neutral locations. +- Brain `tests/test_skill_install.py`: 21 tests passed, covering collisions, + metadata bounds, drift, protected files/links, and symlinked discovery roots. +- Mind `tests/test_ledger_merge.py`: 26 tests passed, including ledger allow and + source-code deny for both `claude/**` and `codex/**`, and workflow triggers. +- Mind `tests/test_codex_hook_sync.py`: 8 tests passed for generated hook config, + opt-in scope, wire names, drift, and absence of an implicit SessionStart copy. + +- Mind end-at-deliverable and assistant API-gate fixture suites: 68 tests passed + for the shared guard implementations. These did not run as live trusted hooks. + +- Mind template-contract and privacy suites: 111 tests passed. Spawned Memory + examples and schema use canonical `AGENTS.md` plus Claude imports. +- Brain Memory corpus suite: 8 tests passed, including canonical-schema recall + and compatibility with older checkouts. +- Memory board suite: 62 tests passed with `PYAUTO_BRAIN` unset for its isolated + workspace-resolution fixture; structure and wikilink validation passed. +- Eight nested instruction migrations preserve their original bodies except + references updated from `CLAUDE.md` to `AGENTS.md`. + +The hook fixtures exercise the shared scripts and adapter generation; they do +not prove that a user's installed session has trusted or invoked project hooks. +Review the current project hook hash with `/hooks` before relying on enforcement. +The Claude remote-session Python bootstrap remains a separate harness adapter; +local development uses the workspace `activate.sh`. + +## Limits and rerun + +This smoke verifies local discovery and adapter wiring. It does not demonstrate +end-to-end fits in Codex, interactive approval handling, actual trusted-hook +execution inside the app server, IDE/cloud parity, or runtime parity with Claude. +Those claims require their own observed runs. Other harnesses can read the shared +instructions; their discovery and hook support has not been measured here. + +To rerun after changing skills, use scoped +`bin/install.sh --check-project-discovery ...`, validate generated +`SKILL.md` folders, then repeat the initialized app-server `skills/list` request +with `forceReload: true` and compare repository-scoped names and errors. Record +the installed CLI version and terminate the smoke process when done. diff --git a/docs/index.md b/docs/index.md index 68c7d4c2..9ddbd3cd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,5 +77,6 @@ example agent_failure_modes codex_usage +agent_harness_smoke workspace_layout ``` diff --git a/skills/prm/prm.md b/skills/prm/prm.md index 0eb4fd24..49cc8196 100644 --- a/skills/prm/prm.md +++ b/skills/prm/prm.md @@ -36,8 +36,8 @@ command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 \ - **`gh`** (local CLI) — `$PYAUTO_ROOT` (`bin/_pyauto_root.sh`) holds the sibling repos; branch detection and worktree cleanup are available. -- **`mcp`** (mobile, web, Codex) — no multi-repo checkout and **no `gh` at - all**: GitHub is the `mcp__github__*` surface, mapped step by step in +- **`mcp`** (a session without authenticated `gh`) — when no local multi-repo + checkout is available, GitHub is the `mcp__github__*` surface, mapped step by step in [`../GITHUB_ACCESS.md`](../GITHUB_ACCESS.md). Drive every step through it, **skip the local-only cleanup** with a one-line note, never `cd` into a repo that isn't there, and never report the close-out blocked for want of `gh`. @@ -287,10 +287,9 @@ Typing `/prm` authorized all of this; the only questions are the guards in step **Push the branch you are on — never force `main`.** On a laptop that branch *is* `main`. On a branch-scoped surface (the phone, claude.ai/code, any - `claude/**` flow) it is the session's branch, and pushing it is the whole + `claude/**` or `codex/**` flow) it is the session's branch, and pushing it is the whole job: a close-out diff is ledger by construction, so - `mind_ledger_merge.yml` merges it into `main` and deletes the branch within - a minute — no PR, and no "merge that branch too" left for the human. Say in + `mind_ledger_merge.yml` merges it into `main` and can delete the branch after its checks pass — no PR, and no "merge that branch too" left for the human. Say in the ledger that the Mind branch was pushed and will land itself. Two things change that: the close-out also touched `scripts/`, `.github/`, `skills/` or another code path (then the branch waits for a human — say so plainly), or diff --git a/skills/prm/reference.md b/skills/prm/reference.md index 5793863b..35aa6ebb 100644 --- a/skills/prm/reference.md +++ b/skills/prm/reference.md @@ -16,7 +16,7 @@ newer flags are absent, so everything below sticks to `gh pr` / `gh api`. # From the current branch (local checkout) gh pr view --json number,url,title,headRefName,state,labels -# Explicit target, no checkout needed (mobile / Codex) +# Explicit target, no checkout needed (remote session) gh pr view -R / --json number,url,title,headRefName,state # Candidates when nothing is claimed: open PRs in one repo diff --git a/skills/start_dev/reference.md b/skills/start_dev/reference.md index 1d95acf3..1ef71ed5 100644 --- a/skills/start_dev/reference.md +++ b/skills/start_dev/reference.md @@ -27,7 +27,7 @@ create_issue's base template): ```markdown ## - issue: -- session: claude --resume +- session: - status: - worktree: ~/Code/PyAutoLabs-wt/ - repos: @@ -195,3 +195,8 @@ execution environments (see [`../WORKFLOW.md`](../WORKFLOW.md)): This is the same reasoning in every environment — only the source of branch state differs (local git vs GitHub API). It is not a separate "mobile mode". + +Resume metadata must describe the active harness. Record `codex resume ` or +`claude --resume ` only when that harness and its real session ID are known; +otherwise retain the known harness and mark the ID unavailable. Preserve earlier +session records verbatim when resuming work from another harness. diff --git a/skills/start_library/start_library.md b/skills/start_library/start_library.md index c635dd17..e7eb1128 100644 --- a/skills/start_library/start_library.md +++ b/skills/start_library/start_library.md @@ -78,7 +78,7 @@ Update the task entry to record the worktree path and claimed repos: ```markdown ## - issue: -- session: claude --resume +- session: - status: library-dev - worktree: ~/Code/PyAutoLabs-wt/ - repos: @@ -110,3 +110,8 @@ test directories, and the implementation steps. End with: "When done, run - If `active.md` has multiple issues, ask which one to work on. - If a repo is already on the correct feature branch, skip creation and note it. - If a repo has uncommitted changes, warn before switching branches. + +Resume metadata must describe the active harness. Record `codex resume ` or +`claude --resume ` only when that harness and its real session ID are known; +otherwise retain the known harness and mark the ID unavailable. Preserve earlier +session records verbatim when resuming work from another harness. diff --git a/skills/start_workspace/reference.md b/skills/start_workspace/reference.md index efef0fef..1580ff50 100644 --- a/skills/start_workspace/reference.md +++ b/skills/start_workspace/reference.md @@ -69,7 +69,7 @@ the workspace repos, set `status: workspace-dev`, add `library-pr:`: ```markdown ## - issue: -- session: claude --resume +- session: - status: workspace-dev - worktree: ~/Code/PyAutoLabs-wt/ - library-pr: @@ -92,3 +92,8 @@ the workspace repos attached (edit these, not the main checkout), the scripts to update/create (with before/after for migrations), and the rules — only edit `scripts/`, never `notebooks/` directly (regenerated by `/ship_workspace`). End with "When done, run `/ship_workspace` to validate and create PRs." + +Resume metadata must describe the active harness. Record `codex resume ` or +`claude --resume ` only when that harness and its real session ID are known; +otherwise retain the known harness and mark the ID unavailable. Preserve earlier +session records verbatim when resuming work from another harness. diff --git a/skills/wake_up/wake_up.md b/skills/wake_up/wake_up.md index c7fb8851..ed5f0b57 100644 --- a/skills/wake_up/wake_up.md +++ b/skills/wake_up/wake_up.md @@ -27,7 +27,7 @@ the identical digest in a terminal when a page is not at hand. safe: both steps are the recoverable, git-aware ones — sync skips repos with real uncommitted work; clean-slate deletes only untracked regenerable artifacts and reports orphan datasets instead of removing them). Skip with - a one-line note when there is no local workspace (mobile/codex). + a one-line note when there is no local workspace (remote session without a checkout). 2. **The digest** — run `bin/pyauto-brain board` and relay its markdown digest (it needs only an authenticated `gh`; degraded sections are listed honestly). If the board CLI cannot run, fall back to the underlying diff --git a/tests/test_memory_surfaces.py b/tests/test_memory_surfaces.py index 5d2276de..121f6c26 100644 --- a/tests/test_memory_surfaces.py +++ b/tests/test_memory_surfaces.py @@ -108,3 +108,15 @@ def test_seed_pages_are_not_a_recall_surface(tmp_path): d = _memory.digest("quasars", m, None, None, 10) assert d["pages"], "the verified pages must still be found" assert all("seed/" not in p["page"] for p in d["pages"]) + + +def test_canonical_schema_is_recalled_instead_of_claude_adapter(tmp_path): + m = _memory_repo(tmp_path) + (m / "wiki/AGENTS.md").write_text("# Shared schema\ncanonical-schema-marker\n") + (m / "wiki/CLAUDE.md").write_text("@AGENTS.md\n") + digest = _memory.digest("canonical-schema-marker", m, None, None, limit=8) + assert [p["page"] for p in digest["pages"]] == ["wiki/AGENTS.md"] + root_files = {str(f.relative_to(m)) for label, _, f in _memory.surfaces(m, None, None) + if label == "PyAutoMemory/root"} + assert "wiki/AGENTS.md" in root_files + assert "wiki/CLAUDE.md" not in root_files From a8740db955b1e634caf832aaba14a1f92882fd09 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:55:13 +0100 Subject: [PATCH 4/9] fix: classify generated assistant discovery adapters Agent: Codex; model: GPT-6 Astra --- agents/conductors/clone/_clone.py | 8 +++++++- tests/test_clone_conductor.py | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/agents/conductors/clone/_clone.py b/agents/conductors/clone/_clone.py index 10d4b213..ab22a074 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 @@ -133,6 +135,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 @@ -150,7 +154,9 @@ "scripts/*.py", # bundled science scripts (a named lens) *_SHARED_DOMAIN, ], - "mixed": _SHARED_MIXED, + # Generated wrappers carry this reference's namespace and skill targets. + # Regenerate them from the newborn's inventory after adapting its skills. + "mixed": [*_SHARED_MIXED, ".codex/skills/*"], "scaffold_dirs": ["wiki/core", "wiki/literature", "dataset", "hpc"], }, # The domain-agnostic base: `af_*` inference skills and the *statistics* diff --git a/tests/test_clone_conductor.py b/tests/test_clone_conductor.py index 3f995ca1..66dfff80 100644 --- a/tests/test_clone_conductor.py +++ b/tests/test_clone_conductor.py @@ -92,6 +92,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", "mixed"), ("wiki/euclid/index.md", "domain"), ("wiki/euclid/entities/vis.md", "domain"), ("wiki/euclid/bibliography/euclid.bib", "domain"), From aa45fc51b732d3e1485c1b93f283904a8f11f5e4 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:56:03 +0100 Subject: [PATCH 5/9] fix: cover inference assistant discovery in clone profile Agent: Codex; model: GPT-6 Astra --- agents/conductors/clone/_clone.py | 3 ++- tests/test_clone_conductor.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/agents/conductors/clone/_clone.py b/agents/conductors/clone/_clone.py index ab22a074..e1046a9d 100644 --- a/agents/conductors/clone/_clone.py +++ b/agents/conductors/clone/_clone.py @@ -172,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 ], @@ -181,7 +182,7 @@ # the worked prompts are domain *_SHARED_DOMAIN, ], - "mixed": _SHARED_MIXED, + "mixed": [*_SHARED_MIXED, ".codex/skills/*"], "scaffold_dirs": ["dataset", "hpc"], }, } diff --git a/tests/test_clone_conductor.py b/tests/test_clone_conductor.py index 66dfff80..a4c3695e 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", "mixed"), # ...and wiki/core teaches statistics here, so it too is GENERIC ("wiki/core/index.md", "generic"), ("wiki/core/concepts/priors.md", "generic"), From bf10dcb899fad476013f5faf290f3efde96e2f9d Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:57:15 +0100 Subject: [PATCH 6/9] brain: derive discovery naming from repository category --- bin/project_discovery.py | 34 +++++++++++++++++++++++----- tests/test_skill_install.py | 44 ++++++++++++++++++++++++++----------- 2 files changed, 60 insertions(+), 18 deletions(-) diff --git a/bin/project_discovery.py b/bin/project_discovery.py index 6e8a4332..8c6db390 100644 --- a/bin/project_discovery.py +++ b/bin/project_discovery.py @@ -10,10 +10,9 @@ import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'agents')) -from _repo_paths import manifest_paths, repo_path +from _repo_paths import bootstrap_repo_path, manifest_paths, repo_path MARKER = '' -ORGAN_NAMES = {'PyAutoBrain', 'PyAutoMind', 'PyAutoHeart', 'PyAutoHands', 'PyAutoMemory', 'autolens_profiling'} SURFACES = ('.claude/commands', '.claude/skills', '.codex/skills') VALID = re.compile(r'[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z') @@ -66,7 +65,7 @@ def adapter(name: str, description: str, link: str) -> str: f'{MARKER}\n\nRead and follow the canonical skill: [SKILL]({link}).\n') -def expected(repo: str, path: Path) -> dict[str, tuple[str, str]]: +def expected(repo: str, path: Path, category: str = "") -> dict[str, tuple[str, str]]: """Relative path -> (symlink, target) or (file, contents).""" skills = path / 'skills' result = {} @@ -82,7 +81,7 @@ def add(key: str, value: tuple[str, str]) -> None: name = entry.name add(f'.claude/skills/{name}', ('symlink', f'../../skills/{name}')) body = entry / 'SKILL.md' - if repo in ORGAN_NAMES: + 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}')) @@ -163,8 +162,33 @@ def actual(path: Path) -> dict[str, tuple[str, str]]: 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) @@ -176,7 +200,7 @@ def run(root: Path, names: list[str], write: bool) -> int: path = repo_path(root, name) if not path.is_dir() or not (path / 'skills').is_dir(): continue - wanted = expected(name, path) + wanted = expected(name, path, categories.get(name, "")) for rel in wanted: if rel.startswith('.codex/skills/'): codex_name = rel.split('/')[2] diff --git a/tests/test_skill_install.py b/tests/test_skill_install.py index 460c6c3c..8954fc60 100644 --- a/tests/test_skill_install.py +++ b/tests/test_skill_install.py @@ -297,17 +297,17 @@ def test_registered_discovery_adapters_and_scoped_write(tmp_path): mind.mkdir(parents=True) (mind / 'repos.yaml').write_text( 'repos:\n PyAutoBrain:\n path: PyAutoBrain\n' - ' autolens_assistant:\n path: lens/autolens_assistant\n' - ' autolens_workspace:\n path: lens/autolens_workspace\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' / 'autolens_assistant' + 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' / 'autolens_workspace' + 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') @@ -315,24 +315,24 @@ def test_registered_discovery_adapters_and_scoped_write(tmp_path): def call(mode, *repos): return subprocess.run(['bash', str(INSTALLER), mode, *repos], env=env, capture_output=True, text=True) - assert call('--check-project-discovery', 'autolens_assistant').returncode == 1 - assert call('--write-project-discovery', 'autolens_assistant').returncode == 0 + 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/autolens-assistant-al-plot/SKILL.md' + adapter = assistant / '.codex/skills/example-assistant-al-plot/SKILL.md' assert adapter.is_file() - assert 'name: autolens-assistant-al-plot' in adapter.read_text() + 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', 'autolens_workspace').returncode == 0 + assert call('--write-project-discovery', 'example_workspace').returncode == 0 assert (workspace / '.claude/skills/open_result').is_symlink() - assert (workspace / '.codex/skills/autolens-workspace-open-result/SKILL.md').is_file() - assert call('--check-project-discovery', 'autolens_assistant', 'autolens_workspace').returncode == 0 - assert call('--write-project-discovery', 'autolens_assistant', 'autolens_workspace').returncode == 0 + 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): @@ -452,7 +452,7 @@ def test_removed_organ_skill_reports_dangling_codex_link_without_deleting(tmp_pa root = tmp_path / 'PyAutoLabs' mind = root / 'PyAutoMind' mind.mkdir(parents=True) - (mind / 'repos.yaml').write_text('repos:\n PyAutoHeart:\n path: PyAutoHeart\n') + (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) @@ -498,3 +498,21 @@ def test_discovery_refuses_symlinked_surface_even_with_no_expected_links(tmp_pat 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() From 68f4c078fec6a23e9e71ecc6d978aa0b6fb11e87 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:57:58 +0100 Subject: [PATCH 7/9] docs: record discovery CI integration checks Agent: Codex; model: GPT-6 Astra --- docs/agent_harness_smoke.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/agent_harness_smoke.md b/docs/agent_harness_smoke.md index 28bd5de6..1f0edd0c 100644 --- a/docs/agent_harness_smoke.md +++ b/docs/agent_harness_smoke.md @@ -54,8 +54,12 @@ read the shared workflow instructions and ran `bin/pyauto-brain help` successful - All eight phase-3 repository discovery checks passed; canonical references resolved and existing Claude links remained usable. - The four workspace skill bodies moved byte-for-byte to their neutral locations. -- Brain `tests/test_skill_install.py`: 21 tests passed, covering collisions, +- Brain `tests/test_skill_install.py`: 22 tests passed, covering body-map categories, collisions, metadata bounds, drift, protected files/links, and symlinked discovery roots. +- Brain clone-profile suite: 35 tests passed. Generated adapters are classified + for both reference assistants; the lensing-assistant tracked boundary passes. + The inference assistant retains 11 pre-existing unclassified script/notebook + paths, with no new unclassified discovery paths. Tenant firewall check passed. - Mind `tests/test_ledger_merge.py`: 26 tests passed, including ledger allow and source-code deny for both `claude/**` and `codex/**`, and workflow triggers. - Mind `tests/test_codex_hook_sync.py`: 8 tests passed for generated hook config, From 17986e2e0bc04b1c1015c96b942292037ee4bf89 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:58:59 +0100 Subject: [PATCH 8/9] fix: regenerate discovery wrappers when cloning assistants Agent: Codex; model: GPT-6 Astra --- agents/conductors/clone/_clone.py | 8 ++++---- tests/test_clone_conductor.py | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/agents/conductors/clone/_clone.py b/agents/conductors/clone/_clone.py index e1046a9d..cda428c3 100644 --- a/agents/conductors/clone/_clone.py +++ b/agents/conductors/clone/_clone.py @@ -107,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/*", @@ -154,9 +156,7 @@ "scripts/*.py", # bundled science scripts (a named lens) *_SHARED_DOMAIN, ], - # Generated wrappers carry this reference's namespace and skill targets. - # Regenerate them from the newborn's inventory after adapting its skills. - "mixed": [*_SHARED_MIXED, ".codex/skills/*"], + "mixed": _SHARED_MIXED, "scaffold_dirs": ["wiki/core", "wiki/literature", "dataset", "hpc"], }, # The domain-agnostic base: `af_*` inference skills and the *statistics* @@ -182,7 +182,7 @@ # the worked prompts are domain *_SHARED_DOMAIN, ], - "mixed": [*_SHARED_MIXED, ".codex/skills/*"], + "mixed": _SHARED_MIXED, "scaffold_dirs": ["dataset", "hpc"], }, } diff --git a/tests/test_clone_conductor.py b/tests/test_clone_conductor.py index a4c3695e..2d0eea83 100644 --- a/tests/test_clone_conductor.py +++ b/tests/test_clone_conductor.py @@ -65,7 +65,7 @@ def test_unknown_reference_fails_cleanly(): ("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", "mixed"), + (".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"), @@ -98,7 +98,7 @@ def test_autofit_profile_classification(path, expected): (".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", "mixed"), + (".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"), @@ -137,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"] From d35285ff5a7abc818bbe2d0abcc0d08ba0a72374 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Sat, 19 Sep 2026 13:59:28 +0100 Subject: [PATCH 9/9] docs: record newborn adapter regression coverage Agent: Codex; model: GPT-6 Astra --- docs/agent_harness_smoke.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent_harness_smoke.md b/docs/agent_harness_smoke.md index 1f0edd0c..8c61f9f9 100644 --- a/docs/agent_harness_smoke.md +++ b/docs/agent_harness_smoke.md @@ -56,7 +56,7 @@ read the shared workflow instructions and ran `bin/pyauto-brain help` successful - The four workspace skill bodies moved byte-for-byte to their neutral locations. - Brain `tests/test_skill_install.py`: 22 tests passed, covering body-map categories, collisions, metadata bounds, drift, protected files/links, and symlinked discovery roots. -- Brain clone-profile suite: 35 tests passed. Generated adapters are classified +- Brain clone-profile suite: 37 tests passed. Generated adapters are classified for both reference assistants; the lensing-assistant tracked boundary passes. The inference assistant retains 11 pre-existing unclassified script/notebook paths, with no new unclassified discovery paths. Tenant firewall check passed.