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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ __pycache__/
|
||||
/skills-archive/
|
||||
/agents-opencode-archive/
|
||||
/agents-claude-archive/
|
||||
/skills-feynman-archive/
|
||||
/agents-feynman-archive/
|
||||
|
||||
@@ -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
385
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/<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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user