diff --git a/.gitignore b/.gitignore index 6f11dee..2973c83 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ __pycache__/ /skills-archive/ /agents-opencode-archive/ /agents-claude-archive/ +/skills-feynman-archive/ +/agents-feynman-archive/ diff --git a/CLAUDE.md b/CLAUDE.md index 4a7f7d3..5ad93d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,8 @@ python3 build.py --install gemini # Gems → generated/_gems/ python3 build.py --install openclaw # IDENTITY.md + 29 personas → generated/_openclaw/ python3 build.py --install opencode # 29 agents + skills → ~/.config/opencode/{agents,skills}/ python3 build.py --install paperclip # 52 agents + 73 skills → generated/_paperclip/ +python3 build.py --install feynman # personas → ~/.feynman/agent/agents/ as Pi-spec subagents (skips researcher/reviewer/writer/verifier collisions); skills → ~/.feynman/agent/skills/ +python3 build.py --install feynman-archive # same as feynman but writes to personas/agents-feynman-archive/ + skills-feynman-archive/ for review without touching ~/.feynman/ python3 build.py --install all # all platforms at once ``` diff --git a/build.py b/build.py index 157f5f8..c167830 100755 --- a/build.py +++ b/build.py @@ -1839,6 +1839,339 @@ def install_opencode( return agent_count +FEYNMAN_BUILTIN_AGENTS = {"researcher", "reviewer", "writer", "verifier"} + + +def _feynman_thinking_for(domain: str) -> str: + HIGH = { + "cybersecurity", + "intelligence", + "military", + "academia", + "law", + "history", + "humanities", + "strategy", + } + return "high" if domain in HIGH else "medium" + + +def _feynman_tools_for(domain: str) -> str | None: + """Return a comma-separated tool allowlist or None to use Pi default.""" + OFFENSIVE = {"cybersecurity"} + if domain in OFFENSIVE: + return ( + "read, write, edit, bash, grep, find, ls, " + "web_search, fetch_content, get_search_content" + ) + INTEL_RESEARCH = {"intelligence", "academia", "history", "military", "law"} + if domain in INTEL_RESEARCH: + return ( + "read, write, edit, grep, find, ls, " + "web_search, fetch_content, get_search_content" + ) + return None # Pi default toolset + + +def install_feynman( + output_dir: Path, + shared_dir: Path | None = None, + topics: set[str] | None = None, + purge_skills: bool = False, + agents_dest: Path | None = None, + skills_dest: Path | None = None, + force_overwrite_builtins: bool = False, +): + """Install personas to Feynman/Pi as custom agents + shared skills. + + Feynman/Pi agent format (per pi-subagents): + Location: ~/.feynman/agent/agents/.md + Frontmatter: name, description, thinking, tools (csv, optional), + output, defaultProgress, inheritProjectContext, + inheritSkills, systemPromptMode. + Body: markdown system prompt. + + Skills land at ~/.feynman/agent/skills//SKILL.md (Pi reads these + natively just like Claude Code does). + + Args: + topics: set of OPENCODE_TOPICS to include (skill filter shared with + opencode). Defaults to OPENCODE_DEFAULT_TOPICS. + purge_skills: when True, wipe ~/.feynman/agent/skills/ first. + Default False to preserve user-installed skills. + agents_dest, skills_dest: archive-mode overrides (used by + --install feynman-archive). + force_overwrite_builtins: emit personas even when their codename + collides with builtin Feynman agents (researcher/reviewer/writer/ + verifier). Default False — collisions are skipped to protect the + bundled prompts. + """ + if topics is None: + topics = OPENCODE_DEFAULT_TOPICS + + if agents_dest is not None: + agents_dir = agents_dest + else: + agents_dir = Path.home() / ".feynman" / "agent" / "agents" + if skills_dest is not None: + skills_dir = skills_dest + else: + skills_dir = Path.home() / ".feynman" / "agent" / "skills" + agents_dir.mkdir(parents=True, exist_ok=True) + skills_dir.mkdir(parents=True, exist_ok=True) + + agent_count = 0 + skipped_builtin = 0 + emitted_agents: set[str] = set() + ident_re = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + for persona_dir in sorted(output_dir.iterdir()): + if not persona_dir.is_dir() or persona_dir.name.startswith("_"): + continue + for variant_json in sorted(persona_dir.glob("*.json")): + try: + data = json.loads(variant_json.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + if not data.get("codename"): + continue + + codename = data["codename"] + variant = data.get("variant") or "general" + name = data.get("name", codename.title()) + role = data.get("role", "Specialist") + domain = data.get("domain", "") + tone = data.get("tone", "") + address_to = data.get("address_to", "") + quote = data.get("quote", "") + skills_list = data.get("skills", []) + + agent_ident = ( + codename if variant == "general" else f"{codename}-{variant}" + ).lower() + if not ident_re.match(agent_ident): + sanitized = re.sub(r"[^a-z0-9]+", "-", agent_ident).strip("-") + if not ident_re.match(sanitized): + print( + f" WARN feynman: cannot sanitize {codename}/{variant}" + ) + continue + agent_ident = sanitized + + if ( + agent_ident in FEYNMAN_BUILTIN_AGENTS + and not force_overwrite_builtins + ): + skipped_builtin += 1 + continue + + sections = data.get("sections", {}) + soul = sections.get("soul", "") + expertise = sections.get("expertise", "") + methodology = sections.get("methodology", "") + behavior = sections.get("behavior_rules", "") + boundaries = sections.get("boundaries", "") + + header = f"You are **{name}** ({address_to}) — {role}" + if variant != "general": + header += f" [{variant}]" + body = header + ".\n\n" + body += f"Domain: {domain} | Tone: {tone}\n\n" + if quote: + body += f'> "{quote}"\n\n' + if soul: + body += "## Soul\n" + soul.strip() + "\n\n" + if expertise: + body += "## Expertise\n" + expertise.strip() + "\n\n" + if methodology: + body += "## Methodology\n" + methodology.strip() + "\n\n" + if behavior: + body += "## Behavior Rules\n" + behavior.strip() + "\n\n" + if boundaries: + body += "## Boundaries\n" + boundaries.strip() + "\n\n" + if skills_list: + body += ( + "## Mapped skills (load on demand via `skill` tool)\n" + + ", ".join(skills_list) + + "\n" + ) + + thinking = _feynman_thinking_for(domain) + tools = _feynman_tools_for(domain) + + if variant == "general": + desc_raw = ( + f"{name} ({address_to}) — {role}. Domain: {domain}." + ) + else: + desc_raw = ( + f"{name} ({address_to}) — {role}. " + f"Variant: {variant}. Domain: {domain}." + ) + desc_safe = desc_raw.replace("\n", " ").replace("\r", " ") + desc_escaped = desc_safe.replace("\\", "\\\\").replace('"', '\\"') + + fm_lines = [ + "---", + f"name: {agent_ident}", + f'description: "{desc_escaped}"', + f"thinking: {thinking}", + ] + if tools: + fm_lines.append(f"tools: {tools}") + fm_lines.extend( + [ + f"output: {agent_ident}-output.md", + "defaultProgress: true", + "inheritProjectContext: false", + "inheritSkills: false", + "systemPromptMode: replace", + "---", + "", + ] + ) + frontmatter = "\n".join(fm_lines) + "\n" + + agent_filename = f"{agent_ident}.md" + (agents_dir / agent_filename).write_text( + frontmatter + body, encoding="utf-8" + ) + emitted_agents.add(agent_filename) + agent_count += 1 + + # Manifest-tracked stale-agent cleanup. Never delete bundled agents + # (researcher / reviewer / writer / verifier) even if they appear in a + # previous manifest — they're protected by FEYNMAN_BUILTIN_AGENTS. + manifest_path = agents_dir / ".personas-manifest.json" + previously_emitted: set[str] = set() + if manifest_path.exists(): + try: + previously_emitted = set( + json.loads(manifest_path.read_text(encoding="utf-8")).get( + "agents", [] + ) + ) + except Exception: + previously_emitted = set() + stale = sorted(previously_emitted - emitted_agents) + pruned = 0 + for name_ in stale: + stem = name_[:-3] if name_.endswith(".md") else name_ + if stem in FEYNMAN_BUILTIN_AGENTS: + continue + stale_path = agents_dir / name_ + if stale_path.exists(): + stale_path.unlink() + pruned += 1 + manifest_path.write_text( + json.dumps( + {"agents": sorted(emitted_agents)}, indent=2, ensure_ascii=False + ), + encoding="utf-8", + ) + if pruned: + print(f" Feynman: pruned {pruned} stale agent file(s)") + + # Install shared skills with same topic filter as opencode. + skill_count = 0 + per_topic: dict[str, int] = {} + skipped_topic = 0 + + if purge_skills and skills_dir.exists(): + import shutil as _shutil + + for existing in skills_dir.iterdir(): + if existing.is_dir(): + _shutil.rmtree(existing) + elif not purge_skills: + print(f" Feynman: preserving existing {skills_dir}/") + + if shared_dir: + import shutil as _shutil + + for skills_subdir in [ + "skills", + "paperclip-skills", + "community-skills", + "feynman-skills", + ]: + src_root = shared_dir / skills_subdir + if not src_root.exists(): + continue + for skill_dir in src_root.iterdir(): + if not skill_dir.is_dir() or skill_dir.name.startswith("_"): + continue + src_skill = skill_dir / "SKILL.md" + if not src_skill.exists(): + continue + sanitized = skill_dir.name.lower() + if not re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", sanitized): + continue + fm = _parse_skill_frontmatter_simple(src_skill) + topic = _classify_skill_topic(skill_dir.name, fm) + if topic not in topics: + skipped_topic += 1 + continue + per_topic[topic] = per_topic.get(topic, 0) + 1 + + dest_dir = skills_dir / sanitized + dest_dir.mkdir(parents=True, exist_ok=True) + (dest_dir / "SKILL.md").write_text( + src_skill.read_text(encoding="utf-8"), encoding="utf-8" + ) + refs = skill_dir / "references" + if refs.exists() and refs.is_dir(): + dest_refs = dest_dir / "references" + dest_refs.mkdir(exist_ok=True) + for ref in refs.iterdir(): + if ref.is_file(): + (dest_refs / ref.name).write_text( + ref.read_text(encoding="utf-8"), + encoding="utf-8", + ) + scripts_src = skill_dir / "scripts" + if scripts_src.exists() and scripts_src.is_dir(): + dest_scripts = dest_dir / "scripts" + if dest_scripts.exists(): + _shutil.rmtree(dest_scripts) + _shutil.copytree(scripts_src, dest_scripts) + data_src = skill_dir / "data" + if data_src.exists() and data_src.is_dir(): + dest_data = dest_dir / "data" + if dest_data.exists(): + _shutil.rmtree(dest_data) + _shutil.copytree(data_src, dest_data) + skill_count += 1 + + # Emit _platform-mapping.md alongside feynman-skills relative refs. + if shared_dir: + pmap_src = shared_dir / "feynman-skills" / "_platform-mapping.md" + if pmap_src.exists(): + (skills_dir / "_platform-mapping.md").write_text( + pmap_src.read_text(encoding="utf-8"), encoding="utf-8" + ) + + print(f" Feynman: {agent_count} agents installed to {agents_dir}") + if skipped_builtin: + print( + f" Feynman: skipped {skipped_builtin} persona(s) — name " + f"conflict with builtin agent (use --skill-force to override)" + ) + print( + f" Feynman skills: {skill_count} installed " + f"({skipped_topic} skipped by topic filter)" + ) + if per_topic: + print( + " Per topic: " + + ", ".join( + f"{k}={v}" + for k, v in sorted(per_topic.items(), key=lambda x: -x[1]) + ) + ) + return agent_count + + def install_gemini(output_dir: Path): """Install personas as Gemini Gems (JSON format for Google AI Studio).""" gems_dir = output_dir / "_gems" @@ -2150,6 +2483,8 @@ def main(): "opencode", "opencode-archive", "paperclip", + "feynman", + "feynman-archive", "all", ], help="Install generated personas to a target platform. " @@ -2157,7 +2492,12 @@ def main(): "shared skills to ~/.claude/skills/ with category filters; " "'opencode-archive' writes opencode-format agents+skills to " "personas/agents-opencode-archive/ + personas/skills-archive/ " - "(consumed by opc-skills/opc-agents) without touching ~/.config/opencode/.", + "(consumed by opc-skills/opc-agents) without touching ~/.config/opencode/. " + "'feynman' deploys personas → ~/.feynman/agent/agents/ as Pi-spec " + "subagents and shared skills → ~/.feynman/agent/skills/. " + "'feynman-archive' writes the same layout to " + "personas/agents-feynman-archive/ + personas/skills-feynman-archive/ " + "for review without touching ~/.feynman/.", ) # --- claude-skills filters -------------------------------------------- parser.add_argument( @@ -2216,6 +2556,13 @@ def main(): "business-pm, uncategorized. " "Default drops marketing/biz. Use 'all' for no filter.", ) + parser.add_argument( + "--feynman-topics", + default=None, + help="Comma-separated topic filter for --install feynman (same " + "vocabulary as --opencode-topics). Default mirrors opencode default. " + "Use 'all' for no filter.", + ) parser.add_argument( "--no-purge", action="store_true", @@ -2355,6 +2702,7 @@ def main(): "openclaw", "opencode", "paperclip", + "feynman", ] else: targets = [args.install] @@ -2414,6 +2762,41 @@ def main(): ) elif target == "paperclip": install_paperclip(output_dir, personas_dir, shared_dir) + elif target in ("feynman", "feynman-archive"): + if args.feynman_topics: + if args.feynman_topics.strip().lower() == "all": + fy_topics = OPENCODE_TOPICS + else: + fy_topics = { + t.strip() + for t in args.feynman_topics.split(",") + if t.strip() + } + else: + fy_topics = None + if target == "feynman-archive": + install_feynman( + output_dir, + shared_dir, + topics=fy_topics, + purge_skills=False, + agents_dest=root / "agents-feynman-archive", + skills_dest=root / "skills-feynman-archive", + force_overwrite_builtins=args.skill_force, + ) + print( + " feynman-archive: wrote review layout to " + "personas/agents-feynman-archive/ + skills-feynman-archive/. " + "Did NOT touch ~/.feynman/." + ) + else: + install_feynman( + output_dir, + shared_dir, + topics=fy_topics, + purge_skills=not args.no_purge, + force_overwrite_builtins=args.skill_force, + ) print_summary(config, len(persona_dirs), total_variants, total_words)