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)
+37 -14
查看文件
@@ -17,16 +17,22 @@ missing or a tag is absent, the breadcrumb degrades to a generic
"Refer to workflow.md for current step." line so users see (and fix)
the broken state instead of the hook silently masking it.
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder,
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI
Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM
in templates/shared-hooks/index.ts — currently Claude, Codex, Gemini,
Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae and ZCode. That table is the
source of truth; each listed platform's collect<Platform>Templates() pulls
this file into its template map through collectSharedHooks(), and a single
writer puts that map on disk at init time. Kiro wires this via the CLI
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context). Written to
each platform's hooks directory via writeSharedHooks() at init time.
(Kiro adds hook stdout directly to the conversation context).
Silent exit 0 cases (no output):
Silent exit 0 case (no output):
- No .trellis/ directory found (not a Trellis project)
- task.json malformed or missing status
When a session points at a task directory whose task.json is missing, malformed,
or missing a usable status, the hook emits a task_error breadcrumb instead of
misreporting the session as having no active task.
"""
from __future__ import annotations
@@ -95,11 +101,16 @@ def find_trellis_root(start: Path) -> Optional[Path]:
def _detect_platform(input_data: dict) -> str | None:
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 every turn reports no_task while the pointer exists on disk.
# Observed on CodeBuddy IDE 4.10.4: session file `codebuddy_ae54840e….json`
# alongside marker `update-check-claude_ae54840e….marker`, same id.
env_map = {
# ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check
# ZCODE first so ZCode sessions aren't misdetected as claude.
"ZCODE_PROJECT_DIR": "zcode",
"CLAUDE_PROJECT_DIR": "claude",
"CURSOR_PROJECT_DIR": "cursor",
"CODEBUDDY_PROJECT_DIR": "codebuddy",
"FACTORY_PROJECT_DIR": "droid",
@@ -108,6 +119,8 @@ def _detect_platform(input_data: dict) -> str | None:
"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):
@@ -145,8 +158,16 @@ def _resolve_active_task(root: Path, input_data: dict):
return resolve_active_task(root, input_data, platform=_detect_platform(input_data))
def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, str]]:
"""Return (task_id, status, source) from the current active task."""
def get_active_task(
root: Path, input_data: dict
) -> tuple[str, str, str] | None:
"""Return active task data, a task-record error, or no task pointer.
``(task_id, "task_error", source)`` is distinct from ``None``: a session
pointer can exist even when its task record is missing or unreadable, and
that state needs a diagnostic breadcrumb rather than the normal ``no_task``
prompt.
"""
active = _resolve_active_task(root, input_data)
if not active.task_path:
return None
@@ -159,16 +180,18 @@ def get_active_task(root: Path, input_data: dict) -> Optional[tuple[str, str, st
task_json = task_dir / "task.json"
if not task_json.is_file():
return None
return task_dir.name, "task_error", active.source
try:
data = json.loads(task_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
return task_dir.name, "task_error", active.source
if not isinstance(data, dict):
return task_dir.name, "task_error", active.source
task_id = data.get("id") or task_dir.name
status = data.get("status", "")
if not isinstance(status, str) or not status:
return None
return task_dir.name, "task_error", active.source
return task_id, status, active.source
+4 -3
查看文件
@@ -128,9 +128,10 @@ def configure_project_encoding(project_dir: Path) -> None:
def _has_curated_jsonl_entry(jsonl_path: Path) -> bool:
"""Return True iff jsonl has at least one row with a ``file`` field.
A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no
``file`` key) — that is NOT "ready". Readiness requires at least one
curated entry. Matches the contract used by ``inject-subagent-context.py``.
A newly created jsonl is empty, and older tasks may still carry a
``{"_example": ...}`` placeholder row (no ``file`` key) — neither is
"ready". Readiness requires at least one curated entry. Matches the
contract used by ``inject-subagent-context.py``.
"""
try:
for line in jsonl_path.read_text(encoding="utf-8").splitlines():