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:
195
build.py
195
build.py
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user