From 0b308ed8bead130af4aabf855c7044db22124d78 Mon Sep 17 00:00:00 2001 From: Salva Date: Sat, 18 Apr 2026 19:12:11 +0300 Subject: [PATCH] fix(install_opencode): emit all variants, spec-compliant frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent emission was only writing general.json for each persona (29 files), skipping 82 variants. Non-general variants now install as: -.md with mode: subagent + hidden: true Key changes to install_opencode agent-emission half: - Loop every *.json in each persona dir, not just general.json - Enforce opencode identifier regex ^[a-z0-9]+(-[a-z0-9]+)*$ with sanitizer - Non-general variants are always subagent (hidden:true is undefined on mode:primary per opencode docs); permission tier still follows domain - Add permission.task block gating subagent dispatch - Wrap description in double quotes with backslash/quote escaping so any special chars (&, :, quotes) can't corrupt the YAML frontmatter - Variant identity surfaced in both description ("Variant: x") and body header "[x]" so prompts self-identify Stale-agent cleanup via sidecar manifest (.personas-manifest.json): prior emission list is tracked so re-runs prune removed variants without touching any hand-authored agents in agents/. Result: 29 -> 111 agents installed, 9 primary + 102 subagent, all picker-clean (Tab cycles 9 canonical offensive personas; variants reach via @codename-variant or task dispatch). Skills-install half intentionally untouched — direct-to-active remains the correct default for users who don't layer opc-skills on top. --- build.py | 195 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 134 insertions(+), 61 deletions(-) diff --git a/build.py b/build.py index 7d048a4..689e527 100755 --- a/build.py +++ b/build.py @@ -1394,79 +1394,152 @@ def install_opencode( } agent_count = 0 + emitted_agents: set[str] = set() + ident_re = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + # Emit one agent file per variant. General → .md (picker-visible). + # Non-general → -.md with hidden:true so it's + # task-dispatchable by name without cluttering the picker. for persona_dir in sorted(output_dir.iterdir()): if not persona_dir.is_dir() or persona_dir.name.startswith("_"): continue - general_json = persona_dir / "general.json" - if not general_json.exists(): - continue - data = json.loads(general_json.read_text(encoding="utf-8")) - codename = data.get("codename", persona_dir.name) - 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 = data.get("skills", []) + 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 - soul = data.get("sections", {}).get("soul", "") - methodology = data.get("sections", {}).get("methodology", "") - behavior = data.get("sections", {}).get("behavior_rules", "") + 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 = data.get("skills", []) - body = f"You are **{name}** ({address_to}) — {role}.\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 methodology: - body += "## Methodology\n" + methodology.strip() + "\n\n" - if behavior: - body += "## Behavior\n" + behavior.strip() + "\n\n" - if skills: - body += "## Mapped Skills\n" + ", ".join(skills) + "\n" + # opencode agent identifier: ^[a-z0-9]+(-[a-z0-9]+)*$ + agent_ident = codename if variant == "general" else f"{codename}-{variant}" + agent_ident = agent_ident.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 skipping {codename}/{variant}: cannot sanitize identifier") + continue + agent_ident = sanitized - is_offensive = domain in OFFENSIVE_DOMAINS - mode = "primary" if is_offensive else "subagent" - color = DOMAIN_COLOR.get(domain, "primary") + soul = data.get("sections", {}).get("soul", "") + methodology = data.get("sections", {}).get("methodology", "") + behavior = data.get("sections", {}).get("behavior_rules", "") - # Permission block differs for offensive vs analytical personas. - if is_offensive: - permission_block = ( - "permission:\n" - " edit: allow\n" - " bash:\n" - ' "*": allow\n' - " webfetch: allow\n" - ) - else: - permission_block = ( - "permission:\n" - " edit: ask\n" - " bash:\n" - ' "*": ask\n' - " webfetch: allow\n" + 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 methodology: + body += "## Methodology\n" + methodology.strip() + "\n\n" + if behavior: + body += "## Behavior\n" + behavior.strip() + "\n\n" + if skills: + body += "## Mapped Skills\n" + ", ".join(skills) + "\n" + + is_offensive = domain in OFFENSIVE_DOMAINS + # Only general variants can be primary (Tab-cycled, top-level). + # All non-general variants are subagents so hidden:true is defined + # per opencode spec ("only applies to mode: subagent") and the Tab + # cycle stays restricted to canonical personas. + if variant == "general" and is_offensive: + mode = "primary" + else: + mode = "subagent" + color = DOMAIN_COLOR.get(domain, "primary") + + # Tier-based permissions. task:"*" gates subagent dispatch so a + # compromised subagent can't silently escalate into another. + if is_offensive: + permission_block = ( + "permission:\n" + " edit: allow\n" + " bash:\n" + ' "*": allow\n' + " webfetch: allow\n" + " task:\n" + ' "*": allow\n' + ) + else: + permission_block = ( + "permission:\n" + " edit: ask\n" + " bash:\n" + ' "*": ask\n' + " webfetch: allow\n" + " task:\n" + ' "*": ask\n' + ) + + 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('"', '\\"') + + hidden_line = "hidden: true\n" if variant != "general" else "" + + frontmatter = ( + "---\n" + f'description: "{desc_escaped}"\n' + f"mode: {mode}\n" + f"{hidden_line}" + "temperature: 0.3\n" + f"color: {color}\n" + f"{permission_block}" + "---\n\n" ) - desc = f"{name} ({address_to}) — {role}. Domain: {domain}.".replace( - "\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 - frontmatter = ( - "---\n" - f"description: {desc}\n" - f"mode: {mode}\n" - "temperature: 0.3\n" - f"color: {color}\n" - f"{permission_block}" - "---\n\n" - ) - agent_file = agents_dir / f"{codename}.md" - agent_file.write_text(frontmatter + body, encoding="utf-8") - agent_count += 1 + # Remove stale agents we emitted on a previous run but not this one. Track + # via sidecar manifest so hand-authored files in agents/ are never touched. + 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) + for name_ in stale: + stale_path = agents_dir / name_ + if stale_path.exists(): + stale_path.unlink() + manifest_path.write_text( + json.dumps( + {"agents": sorted(emitted_agents)}, indent=2, ensure_ascii=False + ), + encoding="utf-8", + ) + if stale: + print(f" OpenCode: pruned {len(stale)} stale agent file(s)") # Install shared skills with topic filter. OpenCode reads SKILL.md with # name+description frontmatter (same as Claude).