feat(install): add --install feynman target for Pi-runtime deployment

Adds install_feynman() that deploys personas as Feynman/Pi custom subagents
and shared skills into the Feynman runtime layout, mirroring the established
opencode pattern.

Layout:
- Personas → ~/.feynman/agent/agents/<codename>.md (Pi-spec frontmatter:
  name, description, thinking, tools, output, defaultProgress,
  inheritProjectContext, inheritSkills, systemPromptMode)
- Skills → ~/.feynman/agent/skills/<name>/ (SKILL.md + references/ +
  scripts/ + data/, plus _platform-mapping.md sibling for feynman-skills
  relative refs)

Defaults & safety:
- thinking inferred per domain (high for cybersec/intel/military/academia/
  law/history/strategy, medium otherwise)
- tools allowlist tighter for offensive (cybersec) and intel/research roles;
  Pi defaults otherwise
- Builtin agents (researcher/reviewer/writer/verifier) protected: persona
  with same codename is skipped unless --skill-force overrides
- Skill topic filter shares OPENCODE_TOPICS vocabulary; new
  --feynman-topics flag mirrors --opencode-topics
- Manifest-tracked stale cleanup never deletes builtins
- purge_skills defaults False (preserves user-installed skills)

Variants:
- --install feynman → live deploy to ~/.feynman/agent/
- --install feynman-archive → review layout to personas/{agents,skills}-
  feynman-archive/ without touching ~/.feynman/

Tested via feynman-archive: 111 persona agents + 1051 skills emitted (516
filtered out by default topic policy). Frontmatter validated against the
real ~/.feynman/agent/agents/ files of a working install.

Adds feynman to the 'all' bundle and updates CLAUDE.md install table +
.gitignore for archive directories.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
salvacybersec
2026-05-01 12:24:41 +03:00
parent 0183a1eb5f
commit 3778fa1694
3 changed files with 388 additions and 1 deletions

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@ __pycache__/
/skills-archive/
/agents-opencode-archive/
/agents-claude-archive/
/skills-feynman-archive/
/agents-feynman-archive/

View File

@@ -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
```

385
build.py
View File

@@ -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/<name>.md
Frontmatter: name, description, thinking, tools (csv, optional),
output, defaultProgress, inheritProjectContext,
inheritSkills, systemPromptMode.
Body: markdown system prompt.
Skills land at ~/.feynman/agent/skills/<name>/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)