chore: 升级 Trellis 并精简规则(09-05-trellis-upgrade-rule-cleanup)

这个提交包含在:
2026-09-06 11:14:41 +08:00
父节点 ab82fe30c0
当前提交 c3cef5cfc4
共修改 48 个文件,包含 4588 行新增和 1053 行删除
+82 -11
查看文件
@@ -92,9 +92,16 @@ def _detect_platform(input_data: dict) -> str | None:
return "codex"
if isinstance(input_data.get("cursor_version"), str):
return "cursor"
# CLAUDE_PROJECT_DIR is a compatibility alias that several hosts set
# alongside their own variable — CodeBuddy, ZCode and Trae all do. It must
# therefore be checked LAST, or every one of them is detected as claude and
# the context key becomes `claude_<their-session-id>`. That key does not
# match the session file `task.py start` wrote under the host's real name,
# so the sub-agent starts with no task context while the pointer exists on
# disk. Same fix as inject-workflow-state.py and session-start.py; this
# third copy was missed when those two were corrected.
env_map = {
"ZCODE_PROJECT_DIR": "zcode",
"CLAUDE_PROJECT_DIR": "claude",
"CURSOR_PROJECT_DIR": "cursor",
"CODEBUDDY_PROJECT_DIR": "codebuddy",
"FACTORY_PROJECT_DIR": "droid",
@@ -102,6 +109,9 @@ def _detect_platform(input_data: dict) -> str | None:
"QODER_PROJECT_DIR": "qoder",
"KIRO_PROJECT_DIR": "kiro",
"COPILOT_PROJECT_DIR": "copilot",
"TRAE_PROJECT_DIR": "trae",
# Last: the shared alias, only meaningful once no vendor key matched.
"CLAUDE_PROJECT_DIR": "claude",
}
for env_name, platform in env_map.items():
if os.environ.get(env_name):
@@ -238,9 +248,33 @@ class _Budget:
self.used += size
def _real_path_contained(base_real: str, target_real: str) -> bool:
"""Whether an already-realpath'd target sits under an already-realpath'd base.
ValueError on Windows when the two sit on different drives; that is
outside the base by definition, so it fails closed.
"""
try:
return os.path.commonpath([base_real, target_real]) == base_real
except ValueError:
return False
def _read_file_bytes(base_path: str, file_path: str) -> bytes | None:
"""Read raw file bytes, return None if file doesn't exist."""
full_path = os.path.join(base_path, file_path)
try:
root_real = os.path.realpath(base_path)
# `.trellis` may itself be a symlink into a store outside the repo
# (#567); its real location is a second legitimate containment base.
workflow_real = os.path.realpath(os.path.join(base_path, ".trellis"))
full_real = os.path.realpath(full_path)
if not _real_path_contained(root_real, full_real) and not (
_real_path_contained(workflow_real, full_real)
):
return None
except OSError:
return None
if os.path.exists(full_path) and os.path.isfile(full_path):
try:
with open(full_path, "rb") as f:
@@ -365,12 +399,12 @@ def read_jsonl_entries(base_path: str, jsonl_path: str) -> list[dict]:
Schema:
{"file": "path/to/file.md", "reason": "..."}
{"file": "path/to/dir/", "type": "directory", "reason": "..."}
{"_example": "..."} # seed row — skipped (no `file` field)
{"_example": "..."} # legacy placeholder — skipped (no `file` field)
Rows without a ``file`` field (e.g. the self-describing seed line written
by ``task.py create`` before the agent has curated entries) are skipped
silently. If the resulting entry list is empty, a stderr warning is
emitted so the operator can debug missing context.
Rows without a ``file`` field (e.g. the placeholder line older Trellis
versions wrote at ``task.py create`` time) are skipped silently. If the
resulting entry list is empty, a stderr warning is emitted so the operator
can debug missing context.
Returns:
[{"file": path, "type": "file" | "directory", "reason": reason}, ...]
@@ -459,6 +493,17 @@ def get_agent_context(
"""
agent_jsonl = f"{task_dir}/{agent_type}.jsonl"
blocks = _materialize_jsonl_entries(repo_root, agent_jsonl, limits, budget)
if not blocks:
# Zero curated context reaches the model silently otherwise — the
# stderr WARN above never enters any session (#573). Put the fact in
# the prompt itself so the sub-agent compensates instead of assuming
# the spec context was complete.
return (
f"[Trellis] {agent_jsonl} has no curated entries, so no spec/research "
"context was injected. Before working, read the guidelines relevant "
"to the code you will touch under .trellis/spec/, and treat the task "
"artifacts below as the only prepared context."
)
return "\n\n".join(blocks)
@@ -877,8 +922,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None:
if not subagent_type or not parent_session_id:
return
cwd = _string_value(input_data.get("cwd")) or os.getcwd()
repo_root = find_repo_root(cwd)
# Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4)
# report "/" for every hook event. See 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
@@ -979,6 +1031,8 @@ def _extract_subagent_type(tool_input: dict) -> str:
"subagentType",
"subagent_type_name",
"subagentTypeName",
"subagent_name",
"subagentName",
"agent_type",
"agentType",
"name",
@@ -994,7 +1048,8 @@ def _parse_hook_input(input_data: dict) -> tuple[str, str, dict]:
Returns (subagent_type, original_prompt, tool_input).
Handles:
- Claude Code / Qoder / CodeBuddy / Droid: tool_name=Task|Agent, tool_input.subagent_type
- Claude Code / Qoder / Droid: tool_name=Task|Agent, tool_input.subagent_type
- CodeBuddy: tool_name=task (IDE) or Task (CLI), tool_input.subagent_name
- Cursor: tool_name=Task|Subagent, tool_input.subagent_type
- Copilot CLI: toolName=task (camelCase key, lowercase value)
- ZCode: toolName=Agent, toolInput/tool_input.subagent_type
@@ -1073,8 +1128,24 @@ def main():
if subagent_type in AGENTS_REQUIRE_TASK:
if not task_dir:
sys.exit(0)
# Check if task directory exists
task_dir_full = os.path.join(repo_root, task_dir)
# Contain the pointer before reading anything through it. `task.py` now
# refuses to store a ref that leaves the repo, but a session file
# written before that fix can still hold one, and `trellis update`
# does not rewrite session files — so a poisoned pointer outlives the
# upgrade that closed the writer. This is the last hop before the
# task's prd.md/design.md reach the model prompt, so it checks again.
try:
root_real = os.path.realpath(repo_root)
# `.trellis` may itself be a symlink into a store outside the
# repo (#567); its real location is a second legitimate base.
workflow_real = os.path.realpath(os.path.join(repo_root, ".trellis"))
task_dir_full = os.path.realpath(os.path.join(repo_root, task_dir))
if not _real_path_contained(root_real, task_dir_full) and not (
_real_path_contained(workflow_real, task_dir_full)
):
sys.exit(0)
except OSError:
sys.exit(0)
if not os.path.exists(task_dir_full):
sys.exit(0)