mirror of
https://github.com/cita-777/metapi.git
synced 2026-11-02 05:05:37 +08:00
1148 lines
36 KiB
Python
1148 lines
36 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
多平台 Sub-Agent 上下文注入 Hook。
|
||
|
||
启动 sub-agent(implement、check、research)时注入任务专属上下文。
|
||
|
||
核心设计原则:
|
||
- Hook 负责注入完整上下文,sub-agent 获得足够信息后自主工作;
|
||
- 每个 agent 使用独立 JSONL 文件声明上下文;
|
||
- 不需要 resume 或分段,行为由代码而不是 prompt 控制。
|
||
|
||
触发事件:PreToolUse(调用 Task 工具之前)
|
||
|
||
上下文来源:Trellis active task resolver 指向的任务目录
|
||
- implement.jsonl - Implement agent 专属上下文
|
||
- check.jsonl - Check agent 专属上下文
|
||
- prd.md - 需求文档
|
||
- design.md - 复杂任务技术设计
|
||
- implement.md - 复杂任务执行计划
|
||
- codex-review-output.txt - Code Review 结果
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
# 重要:先屏蔽所有 warning
|
||
import warnings
|
||
warnings.filterwarnings("ignore")
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
# Hook host 无论进程 locale 如何都发送 UTF-8 JSON。
|
||
_stdin_reconfigure = getattr(sys.stdin, "reconfigure", None)
|
||
if callable(_stdin_reconfigure):
|
||
try:
|
||
_stdin_reconfigure(encoding="utf-8", errors="replace")
|
||
except (OSError, ValueError):
|
||
pass
|
||
|
||
# 重要:Windows 上强制 stdout 使用 UTF-8,避免输出非 ASCII 字符时出现 UnicodeEncodeError。
|
||
if sys.platform.startswith("win"):
|
||
import io as _io
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||
elif hasattr(sys.stdout, "detach"):
|
||
sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||
|
||
|
||
# =============================================================================
|
||
# 路径常量(如需重命名目录,在此处统一修改)
|
||
# =============================================================================
|
||
|
||
DIR_WORKFLOW = ".trellis"
|
||
DIR_SPEC = "spec"
|
||
FILE_TASK_JSON = "task.json"
|
||
|
||
# =============================================================================
|
||
# Subagent 常量(如需重命名角色,在此处统一修改)
|
||
# =============================================================================
|
||
|
||
AGENT_IMPLEMENT = "trellis-implement"
|
||
AGENT_CHECK = "trellis-check"
|
||
AGENT_RESEARCH = "trellis-research"
|
||
|
||
# 需要任务目录的 agent
|
||
AGENTS_REQUIRE_TASK = (AGENT_IMPLEMENT, AGENT_CHECK)
|
||
# 当前支持的全部 agent
|
||
AGENTS_ALL = (AGENT_IMPLEMENT, AGENT_CHECK, AGENT_RESEARCH)
|
||
|
||
|
||
def find_repo_root(start_path: str) -> str | None:
|
||
"""
|
||
从 start_path 向上查找 Git 仓库根目录。
|
||
|
||
返回:
|
||
仓库根目录路径;找不到时返回 None。
|
||
"""
|
||
current = Path(start_path).resolve()
|
||
while current != current.parent:
|
||
if (current / ".git").exists():
|
||
return str(current)
|
||
current = current.parent
|
||
return None
|
||
|
||
|
||
def _detect_platform(input_data: dict) -> str | None:
|
||
if _hook_event_name(input_data) == "SubagentStart":
|
||
return "codex"
|
||
if isinstance(input_data.get("cursor_version"), str):
|
||
return "cursor"
|
||
# CLAUDE_PROJECT_DIR 是多个 host 共用的兼容别名,必须最后检查,避免
|
||
# 覆盖更具体的厂商变量(与 per-turn hook 保持一致)。
|
||
env_map = {
|
||
"ZCODE_PROJECT_DIR": "zcode",
|
||
"CURSOR_PROJECT_DIR": "cursor",
|
||
"CODEBUDDY_PROJECT_DIR": "codebuddy",
|
||
"FACTORY_PROJECT_DIR": "droid",
|
||
"GEMINI_PROJECT_DIR": "gemini",
|
||
"QODER_PROJECT_DIR": "qoder",
|
||
"KIRO_PROJECT_DIR": "kiro",
|
||
"COPILOT_PROJECT_DIR": "copilot",
|
||
"CLAUDE_PROJECT_DIR": "claude",
|
||
}
|
||
for env_name, platform in env_map.items():
|
||
if os.environ.get(env_name):
|
||
return platform
|
||
script_parts = set(Path(sys.argv[0]).parts)
|
||
if ".claude" in script_parts:
|
||
return "claude"
|
||
if ".cursor" in script_parts:
|
||
return "cursor"
|
||
if ".gemini" in script_parts:
|
||
return "gemini"
|
||
if ".qoder" in script_parts:
|
||
return "qoder"
|
||
if ".codebuddy" in script_parts:
|
||
return "codebuddy"
|
||
if ".factory" in script_parts:
|
||
return "droid"
|
||
if ".kiro" in script_parts:
|
||
return "kiro"
|
||
if ".zcode" in script_parts:
|
||
return "zcode"
|
||
return None
|
||
|
||
|
||
def get_current_task(
|
||
repo_root: str,
|
||
input_data: dict,
|
||
*,
|
||
platform: str | None = None,
|
||
allow_single_session_fallback: bool = True,
|
||
allow_environment_context: bool = True,
|
||
require_existing: bool = False,
|
||
) -> str | None:
|
||
"""通过统一的活动任务解析器解析当前任务目录。"""
|
||
scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts"
|
||
if str(scripts_dir) not in sys.path:
|
||
sys.path.insert(0, str(scripts_dir))
|
||
try:
|
||
from common.active_task import resolve_active_task # type: ignore[import-not-found]
|
||
except Exception:
|
||
return None
|
||
|
||
active = resolve_active_task(
|
||
Path(repo_root),
|
||
input_data,
|
||
platform=platform or _detect_platform(input_data),
|
||
allow_single_session_fallback=allow_single_session_fallback,
|
||
allow_environment_context=allow_environment_context,
|
||
)
|
||
if require_existing and active.stale:
|
||
return None
|
||
return active.task_path
|
||
|
||
|
||
# =============================================================================
|
||
# 上下文注入限制(issue #441)
|
||
#
|
||
# 本仓库当前只有这一份 Python 实现;如果以后增加其他平台镜像,修改提示文案时
|
||
# 必须同步对应镜像,避免不同 host 的上下文行为分叉。
|
||
# =============================================================================
|
||
|
||
DEFAULT_MAX_FILE_BYTES = 32768
|
||
DEFAULT_MAX_ARTIFACT_BYTES = 65536
|
||
DEFAULT_MAX_TOTAL_BYTES = 131072
|
||
|
||
DEFAULT_LIMITS: dict[str, int] = {
|
||
"max_file_bytes": DEFAULT_MAX_FILE_BYTES,
|
||
"max_artifact_bytes": DEFAULT_MAX_ARTIFACT_BYTES,
|
||
"max_total_bytes": DEFAULT_MAX_TOTAL_BYTES,
|
||
}
|
||
|
||
|
||
def _get_limits(repo_root: str) -> dict[str, int]:
|
||
"""从 config.yaml 读取上下文注入字节上限,失败时使用安全默认值。"""
|
||
scripts_dir = Path(repo_root) / DIR_WORKFLOW / "scripts"
|
||
if str(scripts_dir) not in sys.path:
|
||
sys.path.insert(0, str(scripts_dir))
|
||
try:
|
||
from common.config import get_context_injection_limits # type: ignore[import-not-found]
|
||
|
||
return get_context_injection_limits(Path(repo_root))
|
||
except Exception:
|
||
return dict(DEFAULT_LIMITS)
|
||
|
||
|
||
def truncate_utf8(data: bytes, cap: int) -> bytes:
|
||
"""将 ``data`` 截断到最多 ``cap`` 字节,且不拆分 UTF-8 多字节序列。
|
||
|
||
``cap <= 0`` 表示“不限制”,原样返回 ``data``。
|
||
"""
|
||
if cap <= 0 or len(data) <= cap:
|
||
return data
|
||
|
||
truncated = data[:cap]
|
||
i = len(truncated)
|
||
# 跳过 UTF-8 continuation byte(10xxxxxx),找到起始字节。
|
||
while i > 0 and (truncated[i - 1] & 0xC0) == 0x80:
|
||
i -= 1
|
||
if i == 0:
|
||
return b""
|
||
|
||
lead = truncated[i - 1]
|
||
if lead & 0x80:
|
||
if (lead & 0xE0) == 0xC0:
|
||
seq_len = 2
|
||
elif (lead & 0xF0) == 0xE0:
|
||
seq_len = 3
|
||
elif (lead & 0xF8) == 0xF0:
|
||
seq_len = 4
|
||
else:
|
||
seq_len = 1
|
||
# 如果完整序列放不下,连起始字节也丢弃。
|
||
if (i - 1) + seq_len > len(truncated):
|
||
i -= 1
|
||
|
||
return truncated[:i]
|
||
|
||
|
||
class _Budget:
|
||
"""跟踪写入 sub-agent 上下文的累计字节数。"""
|
||
|
||
def __init__(self, max_total_bytes: int) -> None:
|
||
self.max_total_bytes = max_total_bytes
|
||
self.used = 0
|
||
|
||
def has_room(self, size: int) -> bool:
|
||
if self.max_total_bytes <= 0:
|
||
return True
|
||
return self.used + size <= self.max_total_bytes
|
||
|
||
def add(self, size: int) -> None:
|
||
self.used += size
|
||
|
||
|
||
def _read_file_bytes(base_path: str, file_path: str) -> bytes | None:
|
||
"""读取原始文件字节;文件不存在时返回 None。"""
|
||
full_path = os.path.join(base_path, file_path)
|
||
if os.path.exists(full_path) and os.path.isfile(full_path):
|
||
try:
|
||
with open(full_path, "rb") as f:
|
||
return f.read()
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _truncate_notice(path: str, cap: int) -> str:
|
||
return f"\n[Trellis:已截断到 {cap} 字节——请阅读 {path} 获取完整内容]"
|
||
|
||
|
||
def _is_binary_content(data: bytes) -> bool:
|
||
"""判断原始字节是否不应解码到模型上下文。"""
|
||
if b"\x00" in data:
|
||
return True
|
||
try:
|
||
data.decode("utf-8", errors="strict")
|
||
except UnicodeDecodeError:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _binary_notice(path: str, size: int, reason: str) -> str:
|
||
return (
|
||
f"[Trellis:未内联(二进制文件)——{path}({size} 字节):{reason}]"
|
||
)
|
||
|
||
|
||
def _index_notice(path: str, size: int, reason: str) -> str:
|
||
return (
|
||
f"[Trellis:未内联(已达到上下文总上限)——{path}({size} 字节):{reason}]"
|
||
)
|
||
|
||
|
||
def _budgeted_block(
|
||
budget: _Budget,
|
||
header: str,
|
||
plain_path: str,
|
||
content: str,
|
||
reason: str,
|
||
size_for_index: int,
|
||
) -> str:
|
||
"""返回内联的 ``=== header ===`` 块;上下文总预算耗尽时退化为索引提示。"""
|
||
block = f"=== {header} ===\n{content}"
|
||
block_bytes = len(block.encode("utf-8"))
|
||
if not budget.has_room(block_bytes):
|
||
notice = _index_notice(plain_path, size_for_index, reason)
|
||
budget.add(len(notice.encode("utf-8")))
|
||
return notice
|
||
budget.add(block_bytes)
|
||
return block
|
||
|
||
|
||
def _materialize_file(
|
||
base_path: str,
|
||
file_path: str,
|
||
reason: str,
|
||
limits: dict[str, int],
|
||
budget: _Budget,
|
||
) -> str | None:
|
||
"""读取 JSONL 引用的文件,应用单文件上限并计入预算。"""
|
||
data = _read_file_bytes(base_path, file_path)
|
||
if data is None:
|
||
return None
|
||
|
||
size = len(data)
|
||
if _is_binary_content(data):
|
||
notice = _binary_notice(file_path, size, reason)
|
||
budget.add(len(notice.encode("utf-8")))
|
||
return notice
|
||
|
||
cap = limits["max_file_bytes"]
|
||
truncated_bytes = truncate_utf8(data, cap)
|
||
content = truncated_bytes.decode("utf-8", errors="replace")
|
||
if len(truncated_bytes) < size:
|
||
content += _truncate_notice(file_path, cap)
|
||
|
||
return _budgeted_block(budget, file_path, file_path, content, reason, size)
|
||
|
||
|
||
def _materialize_directory(
|
||
base_path: str,
|
||
dir_path: str,
|
||
reason: str,
|
||
limits: dict[str, int],
|
||
budget: _Budget,
|
||
max_files: int = 20,
|
||
) -> list[str]:
|
||
"""读取目录中的所有 .md 文件,应用与单文件 JSONL 条目相同的单文件和总量上限。"""
|
||
full_path = os.path.join(base_path, dir_path)
|
||
if not os.path.exists(full_path) or not os.path.isdir(full_path):
|
||
return []
|
||
|
||
blocks: list[str] = []
|
||
try:
|
||
md_files = sorted(
|
||
f
|
||
for f in os.listdir(full_path)
|
||
if f.endswith(".md") and os.path.isfile(os.path.join(full_path, f))
|
||
)
|
||
for filename in md_files[:max_files]:
|
||
relative_path = os.path.join(dir_path, filename)
|
||
block = _materialize_file(base_path, relative_path, reason, limits, budget)
|
||
if block:
|
||
blocks.append(block)
|
||
except Exception:
|
||
pass
|
||
|
||
return blocks
|
||
|
||
|
||
def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]:
|
||
"""
|
||
解析 JSONL 上下文文件引用的所有文件/目录条目。
|
||
|
||
Schema:
|
||
{"file": "path/to/file.md", "reason": "..."}
|
||
{"file": "path/to/dir/", "type": "directory", "reason": "..."}
|
||
{"_example": "..."} # seed 行——跳过(没有 `file` 字段)
|
||
|
||
没有 ``file`` 字段的行(例如 agent 整理条目前由 ``task.py create`` 写入的
|
||
自描述 seed 行)会静默跳过。如果最终条目为空,会向 stderr 输出警告,便于
|
||
操作者排查缺失的上下文。
|
||
|
||
返回:
|
||
[{"file": path, "type": "file" | "directory", "reason": reason}, ...]
|
||
"""
|
||
full_path = os.path.join(base_path, jsonl_path)
|
||
if not os.path.exists(full_path):
|
||
print(
|
||
f"[inject-subagent-context] 警告:找不到 {jsonl_path}——"
|
||
f"sub-agent 只会收到任务产物",
|
||
file=sys.stderr,
|
||
)
|
||
return []
|
||
|
||
entries: list[dict] = []
|
||
saw_real_entry = False
|
||
try:
|
||
with open(full_path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
item = json.loads(line)
|
||
file_path = item.get("file") or item.get("path")
|
||
|
||
if not file_path:
|
||
# seed / 注释行:静默跳过。
|
||
continue
|
||
|
||
saw_real_entry = True
|
||
entries.append(
|
||
{
|
||
"file": file_path,
|
||
"type": item.get("type", "file"),
|
||
"reason": item.get("reason") or "-",
|
||
}
|
||
)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
if not saw_real_entry:
|
||
print(
|
||
f"[inject-subagent-context] 警告:{jsonl_path} 没有整理后的条目(仅有 seed 或为空)——"
|
||
f"sub-agent 只会收到任务产物。请查阅 workflow.md 的规划产物说明。",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
return entries
|
||
|
||
|
||
def _materialize_jsonl_entries(
|
||
base_path: str, jsonl_path: str, limits: dict[str, int], budget: _Budget
|
||
) -> list[str]:
|
||
"""将 JSONL 上下文文件的每个条目展开为上下文块,并应用单文件和总量上限。"""
|
||
blocks: list[str] = []
|
||
for entry in read_jsonl_entries(base_path, jsonl_path):
|
||
if entry["type"] == "directory":
|
||
blocks.extend(
|
||
_materialize_directory(
|
||
base_path, entry["file"], entry["reason"], limits, budget
|
||
)
|
||
)
|
||
else:
|
||
block = _materialize_file(
|
||
base_path, entry["file"], entry["reason"], limits, budget
|
||
)
|
||
if block:
|
||
blocks.append(block)
|
||
return blocks
|
||
|
||
|
||
def get_agent_context(
|
||
repo_root: str,
|
||
task_dir: str,
|
||
agent_type: str,
|
||
limits: dict[str, int],
|
||
budget: _Budget,
|
||
) -> str:
|
||
"""
|
||
获取指定 agent 的 {agent_type}.jsonl 上下文。
|
||
只读取任务系统创建的 implement.jsonl 或 check.jsonl。
|
||
"""
|
||
agent_jsonl = f"{task_dir}/{agent_type}.jsonl"
|
||
blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget)
|
||
return "\n\n".join(blocks)
|
||
|
||
|
||
def _materialize_artifact(
|
||
base_path: str,
|
||
file_path: str,
|
||
header_label: str,
|
||
reason: str,
|
||
limits: dict[str, int],
|
||
budget: _Budget,
|
||
) -> str | None:
|
||
"""读取任务产物(prd/design/implement.md),应用单文件上限并计入预算。"""
|
||
data = _read_file_bytes(base_path, file_path)
|
||
if data is None:
|
||
return None
|
||
|
||
size = len(data)
|
||
cap = limits["max_artifact_bytes"]
|
||
truncated_bytes = truncate_utf8(data, cap)
|
||
content = truncated_bytes.decode("utf-8", errors="replace")
|
||
if len(truncated_bytes) < size:
|
||
content += _truncate_notice(file_path, cap)
|
||
|
||
return _budgeted_block(budget, header_label, file_path, content, reason, size)
|
||
|
||
|
||
def get_implement_context(repo_root: str, task_dir: str) -> str:
|
||
"""
|
||
Implement Agent 的完整上下文。
|
||
|
||
读取顺序:
|
||
1. implement.jsonl 中的所有文件(spec/research manifest);
|
||
2. prd.md(需求);
|
||
3. design.md(如存在,技术设计);
|
||
4. implement.md(如存在,执行计划)。
|
||
"""
|
||
limits = _get_limits(repo_root)
|
||
budget = _Budget(limits["max_total_bytes"])
|
||
context_parts = []
|
||
|
||
# 1. 读取 implement.jsonl
|
||
base_context = get_agent_context(repo_root, task_dir, "implement", limits, budget)
|
||
if base_context:
|
||
context_parts.append(base_context)
|
||
|
||
# 2. 需求文档
|
||
prd_block = _materialize_artifact(
|
||
repo_root,
|
||
f"{task_dir}/prd.md",
|
||
f"{task_dir}/prd.md(需求)",
|
||
"需求文档",
|
||
limits,
|
||
budget,
|
||
)
|
||
if prd_block:
|
||
context_parts.append(prd_block)
|
||
|
||
# 3. 复杂任务的技术设计
|
||
design_block = _materialize_artifact(
|
||
repo_root,
|
||
f"{task_dir}/design.md",
|
||
f"{task_dir}/design.md(技术设计)",
|
||
"技术设计文档",
|
||
limits,
|
||
budget,
|
||
)
|
||
if design_block:
|
||
context_parts.append(design_block)
|
||
|
||
# 4. 复杂任务的执行计划
|
||
implement_plan_block = _materialize_artifact(
|
||
repo_root,
|
||
f"{task_dir}/implement.md",
|
||
f"{task_dir}/implement.md(执行计划)",
|
||
"执行计划文档",
|
||
limits,
|
||
budget,
|
||
)
|
||
if implement_plan_block:
|
||
context_parts.append(implement_plan_block)
|
||
|
||
return "\n\n".join(context_parts)
|
||
|
||
|
||
def get_check_context(repo_root: str, task_dir: str) -> str:
|
||
"""
|
||
Check Agent 的上下文:check.jsonl 加任务产物。
|
||
"""
|
||
limits = _get_limits(repo_root)
|
||
budget = _Budget(limits["max_total_bytes"])
|
||
context_parts = []
|
||
|
||
base_context = get_agent_context(repo_root, task_dir, "check", limits, budget)
|
||
if base_context:
|
||
context_parts.append(base_context)
|
||
|
||
prd_block = _materialize_artifact(
|
||
repo_root,
|
||
f"{task_dir}/prd.md",
|
||
f"{task_dir}/prd.md(需求)",
|
||
"需求文档",
|
||
limits,
|
||
budget,
|
||
)
|
||
if prd_block:
|
||
context_parts.append(prd_block)
|
||
|
||
design_block = _materialize_artifact(
|
||
repo_root,
|
||
f"{task_dir}/design.md",
|
||
f"{task_dir}/design.md(技术设计)",
|
||
"技术设计文档",
|
||
limits,
|
||
budget,
|
||
)
|
||
if design_block:
|
||
context_parts.append(design_block)
|
||
|
||
implement_plan_block = _materialize_artifact(
|
||
repo_root,
|
||
f"{task_dir}/implement.md",
|
||
f"{task_dir}/implement.md(执行计划)",
|
||
"执行计划文档",
|
||
limits,
|
||
budget,
|
||
)
|
||
if implement_plan_block:
|
||
context_parts.append(implement_plan_block)
|
||
|
||
return "\n\n".join(context_parts)
|
||
|
||
|
||
def get_finish_context(repo_root: str, task_dir: str) -> str:
|
||
"""
|
||
Finish 阶段的上下文:复用 check.jsonl 和任务产物。
|
||
Finish 是最终检查,使用同一上下文来源。
|
||
"""
|
||
return get_check_context(repo_root, task_dir)
|
||
|
||
|
||
|
||
def build_implement_prompt(original_prompt: str, context: str) -> str:
|
||
"""构建 Implement 的完整 prompt。"""
|
||
return f"""<!-- trellis-hook-injected -->
|
||
# Implement Agent 任务
|
||
|
||
你是 Multi-Agent Pipeline 中的 Implement Agent。
|
||
|
||
## 上下文
|
||
|
||
所需信息已准备如下:
|
||
|
||
{context}
|
||
|
||
---
|
||
|
||
## 任务
|
||
|
||
{original_prompt}
|
||
|
||
---
|
||
|
||
## 工作流
|
||
|
||
1. **理解规范**——理解上方注入的所有开发规范;
|
||
2. **理解任务产物**——阅读需求,以及存在时的技术设计和执行计划;
|
||
3. **实现功能**——遵循规范和任务产物实现;
|
||
4. **自检**——依据 check 规范确认代码质量。
|
||
|
||
## 重要约束
|
||
|
||
- 不要执行 git commit,只修改代码;
|
||
- 遵循上方注入的全部开发规范;
|
||
- 完成后报告修改/创建的文件列表。"""
|
||
|
||
|
||
def build_check_prompt(original_prompt: str, context: str) -> str:
|
||
"""构建 Check 的完整 prompt。"""
|
||
return f"""<!-- trellis-hook-injected -->
|
||
# Check Agent 任务
|
||
|
||
你是 Multi-Agent Pipeline 中的 Check Agent(代码和跨层检查器)。
|
||
|
||
## 上下文
|
||
|
||
所需的 check 规范和开发规范如下:
|
||
|
||
{context}
|
||
|
||
---
|
||
|
||
## 任务
|
||
|
||
{original_prompt}
|
||
|
||
---
|
||
|
||
## 工作流
|
||
|
||
1. **获取变更**——运行 `git diff --name-only` 和 `git diff` 查看代码变更;
|
||
2. **对照规范**——逐项检查上方规范;
|
||
3. **自行修复**——直接修复问题,不只报告;
|
||
4. **运行验证**——运行项目的 lint 和 typecheck 命令。
|
||
|
||
## 重要约束
|
||
|
||
- 自行修复问题,不只报告;
|
||
- 必须执行 check 规范中的完整清单;
|
||
- 特别注意影响半径分析(L1-L5)。"""
|
||
|
||
|
||
def build_finish_prompt(original_prompt: str, context: str) -> str:
|
||
"""构建 Finish 的完整 prompt(创建 PR 前的最终检查)。"""
|
||
return f"""<!-- trellis-hook-injected -->
|
||
# Finish Agent 任务
|
||
|
||
你正在执行创建 PR 前的最终检查。
|
||
|
||
## 上下文
|
||
|
||
Finish 清单和要求如下:
|
||
|
||
{context}
|
||
|
||
---
|
||
|
||
## 任务
|
||
|
||
{original_prompt}
|
||
|
||
---
|
||
|
||
## 工作流
|
||
|
||
1. **审查变更**——运行 `git diff --name-only` 查看所有变更文件;
|
||
2. **核对任务产物**——检查 prd.md;存在时同时检查 design.md / implement.md;
|
||
3. **同步规范**——判断变更是否引入新的模式、契约或约定:
|
||
- 发现新模式/约定:先读目标规范,再更新该规范,必要时更新 index.md;
|
||
- 基础设施/跨层变更:遵循 update-spec.md 的 7 节必需模板;
|
||
- 纯代码修复且没有新模式:跳过此步;
|
||
4. **运行最终检查**——执行 lint 和 typecheck;
|
||
5. **确认就绪**——确认代码可创建 PR。
|
||
|
||
## 重要约束
|
||
|
||
- 发现缺口时可以更新规范(以 update-spec.md 为指南);
|
||
- 编辑前必须先阅读目标规范(避免重复内容);
|
||
- 不要因为错别字、格式或显而易见的修复更新规范;
|
||
- 发现关键代码问题时要明确报告(修规范,不代替修代码);
|
||
- 核对 prd.md 中的全部验收标准;
|
||
- 存在 design.md / implement.md 时核对其中约束。"""
|
||
|
||
|
||
|
||
def get_research_context(repo_root: str, task_dir: str | None) -> str:
|
||
"""
|
||
返回 Research Agent 的上下文——规范目录的项目结构概览。
|
||
|
||
保留 `task_dir` 参数以与 get_implement_context / get_check_context 的签名一致,
|
||
使调度器可以统一调用。
|
||
"""
|
||
_ = task_dir
|
||
context_parts = []
|
||
|
||
# 1. 项目结构概览(动态发现规范目录)
|
||
spec_path = f"{DIR_WORKFLOW}/{DIR_SPEC}"
|
||
spec_root = Path(repo_root) / DIR_WORKFLOW / DIR_SPEC
|
||
|
||
# 动态构建规范目录树
|
||
tree_lines = [f"{spec_path}/"]
|
||
if spec_root.is_dir():
|
||
pkg_dirs = sorted(d for d in spec_root.iterdir() if d.is_dir())
|
||
for i, pkg_dir in enumerate(pkg_dirs):
|
||
is_last = i == len(pkg_dirs) - 1
|
||
prefix = "└── " if is_last else "├── "
|
||
layers = sorted(d.name for d in pkg_dir.iterdir() if d.is_dir())
|
||
layer_info = f" ({', '.join(layers)})" if layers else ""
|
||
tree_lines.append(f"{prefix}{pkg_dir.name}/{layer_info}")
|
||
|
||
spec_tree = "\n".join(tree_lines)
|
||
|
||
project_structure = f"""## 项目规范目录结构
|
||
|
||
```
|
||
{spec_tree}
|
||
```
|
||
|
||
如需结构化 package 信息,请运行:`python3 ./{DIR_WORKFLOW}/scripts/get_context.py --mode packages`
|
||
|
||
## 搜索提示
|
||
|
||
- 规范文件:`{spec_path}/**/*.md`
|
||
- 代码搜索:使用 Glob 和 Grep 工具
|
||
- 技术资料:使用 mcp__exa__web_search_exa 或 mcp__exa__get_code_context_exa"""
|
||
|
||
context_parts.append(project_structure)
|
||
|
||
return "\n\n".join(context_parts)
|
||
|
||
|
||
def build_research_prompt(original_prompt: str, context: str) -> str:
|
||
"""构建 Research 的完整 prompt。"""
|
||
return f"""# Research Agent 任务(研究代理)
|
||
|
||
你是 Multi-Agent Pipeline 中的 Research Agent(搜索研究员)。
|
||
|
||
## 核心原则
|
||
|
||
**你只做一件事:查找并解释信息。**
|
||
|
||
你是记录者,不是评审者。
|
||
|
||
## 项目信息
|
||
|
||
{context}
|
||
|
||
---
|
||
|
||
## 任务
|
||
|
||
{original_prompt}
|
||
|
||
---
|
||
|
||
## 工作流
|
||
|
||
1. **理解查询**——确定搜索类型(内部/外部)和范围;
|
||
2. **规划搜索**——复杂查询列出搜索步骤;
|
||
3. **执行搜索**——并行执行多个相互独立的搜索;
|
||
4. **整理结果**——输出结构化报告。
|
||
|
||
## 搜索工具
|
||
|
||
| 工具 | 用途 |
|
||
|------|---------|
|
||
| Glob | 按文件名模式搜索 |
|
||
| Grep | 按内容搜索 |
|
||
| Read | 读取文件内容 |
|
||
| mcp__exa__web_search_exa | 外部网页搜索 |
|
||
| mcp__exa__get_code_context_exa | 外部代码/文档搜索 |
|
||
|
||
## 严格边界
|
||
|
||
**只允许**:描述存在什么、位于何处以及如何工作。
|
||
|
||
**禁止**(除非用户明确要求):
|
||
- 提出改进建议;
|
||
- 批评实现;
|
||
- 推荐重构;
|
||
- 修改任何文件。
|
||
|
||
## 报告格式
|
||
|
||
提供结构化搜索结果,包括:
|
||
- 找到的文件列表(含路径);
|
||
- 代码模式分析(如适用);
|
||
- 相关规范文档;
|
||
- 外部参考(如有)。"""
|
||
|
||
|
||
def _string_value(value: Any) -> str:
|
||
if isinstance(value, str):
|
||
stripped = value.strip()
|
||
return stripped
|
||
return ""
|
||
|
||
|
||
def _text_value(value: Any) -> str:
|
||
"""读取文本字段但保留 prompt 的原始空白;非字符串值按空值处理。"""
|
||
return value if isinstance(value, str) else ""
|
||
|
||
|
||
def _hook_event_name(input_data: dict) -> str:
|
||
"""从约定的 snake_case/camelCase 字段读取 hook 事件名称。"""
|
||
return _string_value(
|
||
input_data.get("hook_event_name") or input_data.get("hookEventName")
|
||
)
|
||
|
||
|
||
def _codex_subagent_type(input_data: dict) -> str:
|
||
"""仅在原生启动事件中读取 Trellis Codex agent 类型。"""
|
||
if _hook_event_name(input_data) != "SubagentStart":
|
||
return ""
|
||
agent_type = _string_value(
|
||
input_data.get("agent_type") or input_data.get("agentType")
|
||
)
|
||
return agent_type if agent_type in AGENTS_ALL else ""
|
||
|
||
|
||
def build_codex_subagent_context(
|
||
subagent_type: str,
|
||
task_dir: str,
|
||
context: str,
|
||
) -> str:
|
||
"""为已由 Codex 原生 dispatch 的角色构建开发者上下文。"""
|
||
role = subagent_type.removeprefix("trellis-")
|
||
return f"""<!-- trellis-hook-injected -->
|
||
# Trellis 原生 {role.title()} Subagent
|
||
|
||
你是本任务已 dispatch 的 `{subagent_type}` 角色。请直接执行该角色;不要遵循主
|
||
session 的 dispatch 或等待指令,也不要再创建 Trellis subagent。
|
||
|
||
活动任务:{task_dir}
|
||
|
||
## 已整理上下文
|
||
|
||
{context}"""
|
||
|
||
|
||
def _handle_codex_subagent_start(input_data: dict) -> None:
|
||
"""为识别出的 Trellis 原生 Codex subagent 输出开发者上下文。
|
||
|
||
事件会提供父 session id。这里必须禁用通用的单 session 回退:原生启动绝不能在
|
||
父 id 缺失或过期时借用另一个 Codex 窗口的任务。
|
||
"""
|
||
subagent_type = _codex_subagent_type(input_data)
|
||
parent_session_id = _string_value(input_data.get("session_id"))
|
||
if not subagent_type or not parent_session_id:
|
||
return
|
||
|
||
# 优先使用 payload cwd,再使用当前进程 cwd;某些 host(CodeBuddy IDE 4.10.4)
|
||
# 会为每个 hook 事件报告 "/"。详见 inject-workflow-state.py。
|
||
repo_root = None
|
||
for candidate in (_string_value(input_data.get("cwd")), os.getcwd()):
|
||
if not candidate:
|
||
continue
|
||
repo_root = find_repo_root(candidate)
|
||
if repo_root:
|
||
break
|
||
if not repo_root:
|
||
return
|
||
|
||
task_dir = get_current_task(
|
||
repo_root,
|
||
{"session_id": parent_session_id},
|
||
platform="codex",
|
||
allow_single_session_fallback=False,
|
||
allow_environment_context=False,
|
||
require_existing=True,
|
||
)
|
||
if not task_dir:
|
||
return
|
||
|
||
if subagent_type in AGENTS_REQUIRE_TASK:
|
||
task_dir_full = Path(repo_root) / task_dir
|
||
if not task_dir_full.is_dir():
|
||
return
|
||
|
||
if subagent_type == AGENT_IMPLEMENT:
|
||
context = get_implement_context(repo_root, task_dir)
|
||
elif subagent_type == AGENT_CHECK:
|
||
context = get_check_context(repo_root, task_dir)
|
||
else:
|
||
context = get_research_context(repo_root, task_dir)
|
||
|
||
if not context:
|
||
return
|
||
|
||
output = {
|
||
"hookSpecificOutput": {
|
||
"hookEventName": "SubagentStart",
|
||
"additionalContext": build_codex_subagent_context(
|
||
subagent_type, task_dir, context
|
||
),
|
||
}
|
||
}
|
||
print(json.dumps(output, ensure_ascii=False))
|
||
|
||
|
||
def _extract_subagent_name(value: Any) -> str:
|
||
"""从常见的平台编码中提取 sub-agent 名称。
|
||
|
||
Cursor 原生 Task 参数会把自定义 sub-agent 编码为 protobuf oneof,在 hook JSON
|
||
中可能表现为 ``{"custom": {"name": "..."}}`` 或
|
||
``{"type": {"case": "custom", "value": {"name": "..."}}}``。
|
||
"""
|
||
direct = _string_value(value)
|
||
if direct:
|
||
return direct
|
||
|
||
if not isinstance(value, dict):
|
||
return ""
|
||
|
||
for key in ("name", "subagent_type_name", "subagentTypeName"):
|
||
direct = _string_value(value.get(key))
|
||
if direct:
|
||
return direct
|
||
|
||
custom = value.get("custom")
|
||
if isinstance(custom, dict):
|
||
custom_name = _string_value(custom.get("name"))
|
||
if custom_name:
|
||
return custom_name
|
||
|
||
oneof = value.get("type")
|
||
if isinstance(oneof, dict):
|
||
case_name = _string_value(oneof.get("case"))
|
||
if case_name == "custom":
|
||
nested_value = oneof.get("value")
|
||
if isinstance(nested_value, dict):
|
||
custom_name = _string_value(nested_value.get("name"))
|
||
if custom_name:
|
||
return custom_name
|
||
if case_name:
|
||
return case_name
|
||
|
||
case_name = _string_value(value.get("case"))
|
||
if case_name == "custom":
|
||
nested_value = value.get("value")
|
||
if isinstance(nested_value, dict):
|
||
custom_name = _string_value(nested_value.get("name"))
|
||
if custom_name:
|
||
return custom_name
|
||
if case_name:
|
||
return case_name
|
||
|
||
for agent_name in AGENTS_ALL:
|
||
if agent_name in value:
|
||
return agent_name
|
||
|
||
return ""
|
||
|
||
|
||
def _extract_subagent_type(tool_input: dict) -> str:
|
||
for key in (
|
||
"subagent_type",
|
||
"subagentType",
|
||
"subagent_type_name",
|
||
"subagentTypeName",
|
||
"agent_type",
|
||
"agentType",
|
||
"name",
|
||
):
|
||
agent_name = _extract_subagent_name(tool_input.get(key))
|
||
if agent_name:
|
||
return agent_name
|
||
return ""
|
||
|
||
|
||
def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]:
|
||
"""解析不同平台格式的 hook 输入。
|
||
|
||
返回 ``(subagent_type, original_prompt, tool_input)``。
|
||
支持:
|
||
- Claude Code / Qoder / CodeBuddy / Droid:tool_name=Task|Agent,tool_input.subagent_type
|
||
- Cursor:tool_name=Task|Subagent,tool_input.subagent_type
|
||
- Copilot CLI:toolName=task(camelCase 键,小写值)
|
||
- ZCode:toolName=Agent,toolInput/tool_input.subagent_type
|
||
- Gemini CLI:tool_name 就是 agent 名称(BeforeTool matcher 已完成过滤)
|
||
- Kiro:agentSpawn hook,顶层的 agent_name 字段
|
||
"""
|
||
tool_input = input_data.get("tool_input", {})
|
||
if not isinstance(tool_input, dict):
|
||
tool_input = input_data.get("toolInput", {})
|
||
if not isinstance(tool_input, dict):
|
||
tool_input = {}
|
||
|
||
# 标准格式:带 subagent_type 的 Task/Agent 工具。
|
||
tool_name = _text_value(input_data.get("tool_name"))
|
||
if not tool_name:
|
||
tool_name = _text_value(input_data.get("toolName"))
|
||
if tool_name.lower() in ("task", "agent", "subagent"):
|
||
return (
|
||
_extract_subagent_type(tool_input),
|
||
_text_value(tool_input.get("prompt")),
|
||
tool_input,
|
||
)
|
||
|
||
# Kiro:agentSpawn hook 将 agent_name 放在顶层。
|
||
agent_name = _string_value(input_data.get("agent_name"))
|
||
if agent_name:
|
||
prompt = _text_value(tool_input.get("prompt"))
|
||
if not prompt:
|
||
prompt = _text_value(input_data.get("prompt"))
|
||
return agent_name, prompt, tool_input
|
||
|
||
# Gemini CLI:BeforeTool 的 tool_name 就是 agent 名称
|
||
#(matcher 已确认它属于受支持的 agent)。
|
||
if tool_name in AGENTS_ALL:
|
||
return tool_name, _text_value(tool_input.get("prompt")), tool_input
|
||
|
||
# Copilot CLI:toolName 字段(camelCase),值可能就是 agent 名称。
|
||
tool_name_camel = _text_value(input_data.get("toolName"))
|
||
if tool_name_camel in AGENTS_ALL:
|
||
return tool_name_camel, _text_value(input_data.get("toolArgs")), tool_input
|
||
|
||
return "", "", tool_input
|
||
|
||
|
||
def main():
|
||
if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1":
|
||
sys.exit(0)
|
||
|
||
try:
|
||
input_data = json.load(sys.stdin)
|
||
except json.JSONDecodeError:
|
||
sys.exit(0)
|
||
if not isinstance(input_data, dict):
|
||
sys.exit(0)
|
||
|
||
if _hook_event_name(input_data) == "SubagentStart":
|
||
try:
|
||
_handle_codex_subagent_start(input_data)
|
||
except Exception:
|
||
# 原生上下文 hook 绝不能因为运行时状态不可用或过期而阻止 Codex
|
||
# 启动请求的子 agent。
|
||
pass
|
||
sys.exit(0)
|
||
|
||
subagent_type, original_prompt, tool_input = _parse_hook_input(input_data)
|
||
cwd_value = input_data.get("cwd")
|
||
cwd = cwd_value if isinstance(cwd_value, str) and cwd_value else os.getcwd()
|
||
|
||
# 只处理受支持的 subagent 类型。
|
||
if subagent_type not in AGENTS_ALL:
|
||
sys.exit(0)
|
||
|
||
# 查找仓库根目录。
|
||
repo_root = find_repo_root(cwd)
|
||
if not repo_root:
|
||
sys.exit(0)
|
||
|
||
# 获取当前任务目录(research 不要求任务目录)
|
||
task_dir = get_current_task(repo_root, input_data)
|
||
|
||
# implement/check 需要任务目录。
|
||
if subagent_type in AGENTS_REQUIRE_TASK:
|
||
if not task_dir:
|
||
sys.exit(0)
|
||
# 确认任务目录存在。
|
||
task_dir_full = os.path.join(repo_root, task_dir)
|
||
if not os.path.exists(task_dir_full):
|
||
sys.exit(0)
|
||
|
||
# 检查 prompt 中的 [finish] 标记(带收尾上下文的 check agent)。
|
||
is_finish_phase = "[finish]" in original_prompt.lower()
|
||
|
||
# 按 subagent 类型获取上下文并构建 prompt
|
||
if subagent_type == AGENT_IMPLEMENT:
|
||
assert task_dir is not None # 上方已完成校验。
|
||
context = get_implement_context(repo_root, task_dir)
|
||
new_prompt = build_implement_prompt(original_prompt, context)
|
||
elif subagent_type == AGENT_CHECK:
|
||
assert task_dir is not None # 上方已完成校验。
|
||
if is_finish_phase:
|
||
# Finish 阶段使用更轻量、聚焦最终验证的上下文。
|
||
context = get_finish_context(repo_root, task_dir)
|
||
new_prompt = build_finish_prompt(original_prompt, context)
|
||
else:
|
||
# 普通检查阶段:使用 check context(完整规范,用于自修复循环)。
|
||
context = get_check_context(repo_root, task_dir)
|
||
new_prompt = build_check_prompt(original_prompt, context)
|
||
elif subagent_type == AGENT_RESEARCH:
|
||
# Research 可以不依赖任务目录。
|
||
context = get_research_context(repo_root, task_dir)
|
||
new_prompt = build_research_prompt(original_prompt, context)
|
||
else:
|
||
sys.exit(0)
|
||
|
||
if not context:
|
||
sys.exit(0)
|
||
|
||
# 返回更新后的输入。多数平台会忽略未知字段,因此同时提供多种格式。
|
||
# ZCode 更严格;实测确认下面的 Claude 兼容嵌套形状可以到达 sub-agent prompt。
|
||
updated = {**tool_input, "prompt": new_prompt}
|
||
if _detect_platform(input_data) == "zcode":
|
||
output = {
|
||
"hookSpecificOutput": {
|
||
"hookEventName": "PreToolUse",
|
||
"permissionDecision": "allow",
|
||
"updatedInput": updated,
|
||
}
|
||
}
|
||
else:
|
||
output = {
|
||
# Claude Code / Qoder / CodeBuddy / Droid 格式。
|
||
"hookSpecificOutput": {
|
||
"hookEventName": "PreToolUse",
|
||
"permissionDecision": "allow",
|
||
"updatedInput": updated,
|
||
},
|
||
# Cursor 格式。
|
||
"permission": "allow",
|
||
"updated_input": updated,
|
||
# Gemini 格式。
|
||
"updatedInput": updated,
|
||
}
|
||
|
||
print(json.dumps(output, ensure_ascii=False))
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|