chore: 升级 Trellis 并精简规则(09-05-trellis-upgrade-rule-cleanup)
这个提交包含在:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
在新工单中引用
屏蔽一个用户