agent_profile.py 7.13 KB
"""Org Agent identity: compiles migrated Hermes personas into Claude Code
subagent definitions and deploys them into a target project (U-004, A-2 assumption).

Source content: `agents/<org_node>/SOUL.md` (+ `AGENTS.md` for validator) in
graphlemon's own repo -- the migrated/adapted personas, see those files' own
migration notes for what changed and why.

Deploy target: `<target_folder_path>/.claude/agents/<org_node>.md`. Confirmed
with user 2026-07-31: 03-C §3.3 ("让整个会话以目标项目 .claude/agents/<org_node>.md
定义的 subagent 身份运行") and 05 §5.1 are the authoritative SDD contract here --
Claude Code's `--agent <name>` discovers subagents relative to the invocation
cwd, so the file has to physically be inside whatever directory `claude` is
run from (the target project), not graphlemon's own repo. Architecture doc
§13.1's "agents/ is a platform-only asset" framing is about the *canonical*
copy (this module's source), not the deployed one -- the two aren't the same
file.

agent_profile (01-M 1.9) is a singleton per org_node, has no target-project
reference -- it tracks graphlemon's own canonical definition, not a per-deploy
record, so deploy_agent_definitions() doesn't touch it; record_agent_profiles()
does, separately.
"""

import os

_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_AGENTS_SOURCE_DIR = os.path.join(_REPO_ROOT, "agents")

ORG_NODES = ("planner", "coder", "validator")

_MIGRATED_FROM_PROFILE = {
    "planner": "planning",
    "coder": "code",
    "validator": "qa",
}

_DESCRIPTIONS = {
    "planner": "graphlemon Planner: turns a goal into BRD/PRD/SDD/Benchmark artifacts. Never writes business code.",
    "coder": "graphlemon Coder: implements one task's code + tests against the Planner's SDD.",
    "validator": "graphlemon Validator: independently verifies a task, PASS/FAIL/INCONCLUSIVE, read-only on source.",
}

# 05 §5.1: skills:/memory:/tools:/model: (and, for validator, disallowedTools: --
# the qa/AGENTS.md workspace-permission-contract folded in) are the frontmatter
# fields U-004's Done criterion checks for.
FRONTMATTER = {
    "planner": {
        "memory": "project",
        "model": "claude-sonnet-5",
        "skills": [
            "planning-requirement-expansion",
            "project-brd-planning",
            "project-prd-planning",
            "project-sdd-planning",
            "project-unit-decomposition",
            "review-blocker-verification",
        ],
        "tools": ["Read", "Write", "Grep", "Glob"],
    },
    "coder": {
        "memory": "project",
        "model": "claude-sonnet-5",
        "skills": [
            "code-task-development",
            "code-agent-engineering",
            "debugging-toolkit",
            "software-planning-execution",
            "design-dynamic-work-graph",
        ],
        "tools": ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
    },
    "validator": {
        "memory": "project",
        "model": "claude-sonnet-5",
        "skills": ["qa-task-validation", "unattended-agent-operations", "design-dynamic-work-graph"],
        "tools": ["Read", "Bash", "Grep", "Glob"],
        # 2026-07-31 fix, found for real running U-014's full closed loop:
        # scoped Bash patterns here ("Bash(git commit:*)") don't just block
        # that one subcommand -- they silently disable Bash *entirely* for
        # the agent (confirmed by isolating it: a plain ["Edit","Write"]
        # disallowedTools list leaves Bash working fine; adding any
        # "Bash(...)" entry breaks it, even though the CLI's own
        # --disallowedTools flag documents this exact scoped syntax as
        # supported -- it just doesn't work the same way in subagent
        # frontmatter). A Validator that can't run tests at all is a much
        # worse outcome than the residual risk of it running `git commit`,
        # so the git-mutation restriction now relies on the prose instruction
        # already in the migrated AGENTS.md ("不提交、推送、reset、清理或覆盖用户
        # 工作树") instead of a tool-level block that doesn't actually work.
        # ponytail: this is a real gap (no enforced ceiling on Bash git
        # mutations, just an asked-nicely one) -- upgrade path is whatever
        # Claude Code's correct scoped-disallowedTools frontmatter syntax
        # turns out to be, if this gets fixed/documented upstream.
        "disallowedTools": ["Edit", "Write"],
    },
}


def _soul_path(org_node):
    return os.path.join(_AGENTS_SOURCE_DIR, org_node, "SOUL.md")


def _agents_md_path(org_node):
    return os.path.join(_AGENTS_SOURCE_DIR, org_node, "AGENTS.md")


def _read(path):
    with open(path, encoding="utf-8") as f:
        return f.read()


def _frontmatter_yaml(org_node, fm):
    lines = ["---", f"name: {org_node}", f"description: {_DESCRIPTIONS[org_node]}"]
    lines.append(f"memory: {fm['memory']}")
    lines.append(f"model: {fm['model']}")
    lines.append("skills:")
    lines += [f"  - {s}" for s in fm["skills"]]
    lines.append("tools:")
    lines += [f"  - {t}" for t in fm["tools"]]
    if "disallowedTools" in fm:
        lines.append("disallowedTools:")
        lines += [f"  - {t}" for t in fm["disallowedTools"]]
    lines.append("---")
    return "\n".join(lines)


def render_agent_definition(org_node):
    """Assemble the .claude/agents/<org_node>.md content: frontmatter + migrated body."""
    if org_node not in ORG_NODES:
        raise ValueError(org_node)
    body = _read(_soul_path(org_node))
    agents_md_path = _agents_md_path(org_node)
    if os.path.exists(agents_md_path):
        body += "\n\n" + _read(agents_md_path)
    return _frontmatter_yaml(org_node, FRONTMATTER[org_node]) + "\n\n" + body


def deploy_agent_definitions(target_folder_path):
    """Write .claude/agents/{planner,coder,validator}.md into a target project."""
    out_dir = os.path.join(target_folder_path, ".claude", "agents")
    os.makedirs(out_dir, exist_ok=True)
    for org_node in ORG_NODES:
        with open(os.path.join(out_dir, org_node + ".md"), "w", encoding="utf-8") as f:
            f.write(render_agent_definition(org_node))


def record_agent_profiles(conn, current_git_commit_sha):
    """Upsert the agent_profile (01-M 1.9) singleton rows for graphlemon's own repo."""
    for org_node in ORG_NODES:
        agents_md_path = _agents_md_path(org_node)
        agents_file_path = os.path.relpath(agents_md_path, _REPO_ROOT) if os.path.exists(agents_md_path) else None
        conn.execute(
            "INSERT INTO agent_profile "
            "(org_node, soul_file_path, agents_file_path, migrated_from_profile, current_git_commit_sha) "
            "VALUES (?, ?, ?, ?, ?) "
            "ON CONFLICT(org_node) DO UPDATE SET "
            "soul_file_path=excluded.soul_file_path, "
            "agents_file_path=excluded.agents_file_path, "
            "migrated_from_profile=excluded.migrated_from_profile, "
            "current_git_commit_sha=excluded.current_git_commit_sha",
            (
                org_node,
                os.path.relpath(_soul_path(org_node), _REPO_ROOT),
                agents_file_path,
                _MIGRATED_FROM_PROFILE[org_node],
                current_git_commit_sha,
            ),
        )
    conn.commit()