fix(install_opencode): emit all variants, spec-compliant frontmatter

Agent emission was only writing general.json for each persona (29 files),
skipping 82 variants. Non-general variants now install as:
  <codename>-<variant>.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.
This commit is contained in:
2026-04-18 19:12:11 +03:00
parent f2f90abf13
commit 0b308ed8be

195
build.py
View File

@@ -1394,79 +1394,152 @@ def install_opencode(
} }
agent_count = 0 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 → <codename>.md (picker-visible).
# Non-general → <codename>-<variant>.md with hidden:true so it's
# task-dispatchable by name without cluttering the picker.
for persona_dir in sorted(output_dir.iterdir()): for persona_dir in sorted(output_dir.iterdir()):
if not persona_dir.is_dir() or persona_dir.name.startswith("_"): if not persona_dir.is_dir() or persona_dir.name.startswith("_"):
continue continue
general_json = persona_dir / "general.json"
if not general_json.exists():
continue
data = json.loads(general_json.read_text(encoding="utf-8")) for variant_json in sorted(persona_dir.glob("*.json")):
codename = data.get("codename", persona_dir.name) try:
name = data.get("name", codename.title()) data = json.loads(variant_json.read_text(encoding="utf-8"))
role = data.get("role", "Specialist") except json.JSONDecodeError:
domain = data.get("domain", "") continue
tone = data.get("tone", "") if not data.get("codename"):
address_to = data.get("address_to", "") continue
quote = data.get("quote", "")
skills = data.get("skills", [])
soul = data.get("sections", {}).get("soul", "") codename = data["codename"]
methodology = data.get("sections", {}).get("methodology", "") variant = data.get("variant") or "general"
behavior = data.get("sections", {}).get("behavior_rules", "") 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" # opencode agent identifier: ^[a-z0-9]+(-[a-z0-9]+)*$
body += f"Domain: {domain} | Tone: {tone}\n\n" agent_ident = codename if variant == "general" else f"{codename}-{variant}"
if quote: agent_ident = agent_ident.lower()
body += f'> "{quote}"\n\n' if not ident_re.match(agent_ident):
if soul: sanitized = re.sub(r"[^a-z0-9]+", "-", agent_ident).strip("-")
body += "## Soul\n" + soul.strip() + "\n\n" if not ident_re.match(sanitized):
if methodology: print(f" WARN skipping {codename}/{variant}: cannot sanitize identifier")
body += "## Methodology\n" + methodology.strip() + "\n\n" continue
if behavior: agent_ident = sanitized
body += "## Behavior\n" + behavior.strip() + "\n\n"
if skills:
body += "## Mapped Skills\n" + ", ".join(skills) + "\n"
is_offensive = domain in OFFENSIVE_DOMAINS soul = data.get("sections", {}).get("soul", "")
mode = "primary" if is_offensive else "subagent" methodology = data.get("sections", {}).get("methodology", "")
color = DOMAIN_COLOR.get(domain, "primary") behavior = data.get("sections", {}).get("behavior_rules", "")
# Permission block differs for offensive vs analytical personas. header = f"You are **{name}** ({address_to}) — {role}"
if is_offensive: if variant != "general":
permission_block = ( header += f" [{variant}]"
"permission:\n" body = header + ".\n\n"
" edit: allow\n" body += f"Domain: {domain} | Tone: {tone}\n\n"
" bash:\n" if quote:
' "*": allow\n' body += f'> "{quote}"\n\n'
" webfetch: allow\n" if soul:
) body += "## Soul\n" + soul.strip() + "\n\n"
else: if methodology:
permission_block = ( body += "## Methodology\n" + methodology.strip() + "\n\n"
"permission:\n" if behavior:
" edit: ask\n" body += "## Behavior\n" + behavior.strip() + "\n\n"
" bash:\n" if skills:
' "*": ask\n' body += "## Mapped Skills\n" + ", ".join(skills) + "\n"
" webfetch: allow\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( agent_filename = f"{agent_ident}.md"
"\n", " " (agents_dir / agent_filename).write_text(
) frontmatter + body, encoding="utf-8"
)
emitted_agents.add(agent_filename)
agent_count += 1
frontmatter = ( # Remove stale agents we emitted on a previous run but not this one. Track
"---\n" # via sidecar manifest so hand-authored files in agents/ are never touched.
f"description: {desc}\n" manifest_path = agents_dir / ".personas-manifest.json"
f"mode: {mode}\n" previously_emitted: set[str] = set()
"temperature: 0.3\n" if manifest_path.exists():
f"color: {color}\n" try:
f"{permission_block}" previously_emitted = set(
"---\n\n" json.loads(manifest_path.read_text(encoding="utf-8")).get("agents", [])
) )
agent_file = agents_dir / f"{codename}.md" except Exception:
agent_file.write_text(frontmatter + body, encoding="utf-8") previously_emitted = set()
agent_count += 1 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 # Install shared skills with topic filter. OpenCode reads SKILL.md with
# name+description frontmatter (same as Claude). # name+description frontmatter (same as Claude).