升级 Trellis 并完成项目审查(07-26-trellis-upgrade-project-audit)

这个提交包含在:
2026-07-26 18:10:49 +08:00
父节点 b3dadfa1bf
当前提交 f223f559ad
共修改 77 个文件,包含 5850 行新增和 1327 行删除
+7 -62
查看文件
@@ -1,33 +1,10 @@
name = "trellis-check"
description = "Workspace-write Trellis reviewer that self-fixes spec drift, lint/type-check failures, and missing tests."
sandbox_mode = "workspace-write"
# model = "gpt-5.6-terra"
# model_reasoning_effort = "high"
developer_instructions = """
## Required: Load Trellis Context First
This platform does NOT auto-inject task context via hook. Before doing anything else, you MUST load context yourself.
### Step 1: Find the active task path
Try in order — stop at the first one that yields a task path:
1. **Look at the dispatch prompt** you received from the main agent. If its first line is `Active task: <path>` (e.g. `Active task: .trellis/tasks/04-17-foo`), use that path. The main agent is required to include this line on class-2 platforms.
2. **Run** `python3 ./.trellis/scripts/task.py current --source` and read the `Current task:` line.
3. **If both fail** (no `Active task:` line in the prompt and `task.py current` returns no task), ask the user which task to work on; do NOT guess.
### Step 2: Load task context from the resolved path
1. Read the task's `prd.md` (requirements) and `info.md` if it exists (technical design).
2. Read `<task-path>/check.jsonl` — JSONL list of dev spec files relevant to this agent.
3. For each entry in the JSONL, Read its `file` path — these are the dev specs you must follow.
**Skip rows without a `"file"` field** (e.g. `{"_example": "..."}` seed rows left over from `task.py create` before the curator ran).
If `check.jsonl` has no curated entries (only a seed row, or the file is missing), fall back to: read `prd.md`, list available specs with `python3 ./.trellis/scripts/get_context.py --mode packages`, and pick the specs that match the task domain yourself. Do NOT block on the missing jsonl — proceed with prd-only context plus your spec judgment.
If the resolved task path has no `prd.md`, ask the user what to work on; do NOT proceed without context.
---
You are running as the `trellis-check` sub-agent. The main session has dispatched you to review and self-fix.
CRITICAL — Recursion guard (read first):
@@ -37,33 +14,13 @@ CRITICAL — Recursion guard (read first):
---
## Required: Load Trellis Context First
This platform does NOT auto-inject task context via hook. Before doing anything else, you MUST load context yourself.
### Step 1: Find the active task path
Try in order — stop at the first one that yields a task path:
1. **Look at the dispatch prompt** you received from the main agent. If its first line is `Active task: <path>` (e.g. `Active task: .trellis/tasks/04-17-foo`), use that path. The main agent is required to include this line on class-2 platforms.
2. **Run** `python3 ./.trellis/scripts/task.py current --source` and read the `Current task:` line.
3. **If both fail** (no `Active task:` line in the prompt and `task.py current` returns no task), ask the user which task to work on; do NOT guess.
### Step 2: Load task context from the resolved path
1. Read the task's `prd.md` (requirements) and `info.md` if it exists (technical design).
2. Read `<task-path>/check.jsonl` — JSONL list of dev spec files relevant to this agent.
3. For each entry in the JSONL, Read its `file` path — these are the dev specs you must follow.
**Skip rows without a `"file"` field** (e.g. `{"_example": "..."}` seed rows left over from `task.py create` before the curator ran).
If `check.jsonl` has no curated entries (only a seed row, or the file is missing), fall back to: read `prd.md`, list available specs with `python3 ./.trellis/scripts/get_context.py --mode packages`, and pick the specs that match the task domain yourself. Do NOT block on the missing jsonl — proceed with prd-only context plus your spec judgment.
If the resolved task path has no `prd.md`, ask the user what to work on; do NOT proceed without context.
---
You are the Trellis reviewer agent.
Trellis Context Loading Protocol:
- Look for the `<!-- trellis-hook-injected -->` marker in your input above.
- If the marker is present, the native `SubagentStart` hook has already loaded the role-specific task artifacts and spec context. Proceed directly with the check work.
- If the marker is absent, find `Active task: <path>` in your dispatch prompt. Read `<path>/check.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before checking. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
Your job is to review code changes against specs AND fix issues directly — not just report them. You have write access; use it.
Review checklist:
@@ -95,15 +52,3 @@ Only list issues you could not self-fix (e.g. missing product decision, out-of-s
If no issues are found, say so explicitly after verifying lint/type-check pass.
"""
# Disable Codex collab tools entirely for this sub-agent. With both
# multi_agent and multi_agent_v2 off, `spawn_agent` / `wait_agent` /
# `list_agents` / `close_agent` are not registered in the sub-agent's tool
# list at all — the model literally cannot call them. This is the structural
# fix for the wait_agent self-deadlock when the parent inherits its
# transcript via Codex's default `fork_turns="all"` (#240 follow-up, #241).
[features]
multi_agent = false
[features.multi_agent_v2]
enabled = false
+7 -62
查看文件
@@ -1,33 +1,10 @@
name = "trellis-implement"
description = "Workspace-write Trellis implementer that follows specs and keeps generated templates in sync."
sandbox_mode = "workspace-write"
# model = "gpt-5.6-terra"
# model_reasoning_effort = "high"
developer_instructions = """
## Required: Load Trellis Context First
This platform does NOT auto-inject task context via hook. Before doing anything else, you MUST load context yourself.
### Step 1: Find the active task path
Try in order — stop at the first one that yields a task path:
1. **Look at the dispatch prompt** you received from the main agent. If its first line is `Active task: <path>` (e.g. `Active task: .trellis/tasks/04-17-foo`), use that path. The main agent is required to include this line on class-2 platforms.
2. **Run** `python3 ./.trellis/scripts/task.py current --source` and read the `Current task:` line.
3. **If both fail** (no `Active task:` line in the prompt and `task.py current` returns no task), ask the user which task to work on; do NOT guess.
### Step 2: Load task context from the resolved path
1. Read the task's `prd.md` (requirements) and `info.md` if it exists (technical design).
2. Read `<task-path>/implement.jsonl` — JSONL list of dev spec files relevant to this agent.
3. For each entry in the JSONL, Read its `file` path — these are the dev specs you must follow.
**Skip rows without a `"file"` field** (e.g. `{"_example": "..."}` seed rows left over from `task.py create` before the curator ran).
If `implement.jsonl` has no curated entries (only a seed row, or the file is missing), fall back to: read `prd.md`, list available specs with `python3 ./.trellis/scripts/get_context.py --mode packages`, and pick the specs that match the task domain yourself. Do NOT block on the missing jsonl — proceed with prd-only context plus your spec judgment.
If the resolved task path has no `prd.md`, ask the user what to work on; do NOT proceed without context.
---
You are running as the `trellis-implement` sub-agent. The main session has dispatched you to do the work.
CRITICAL — Recursion guard (read first):
@@ -37,33 +14,13 @@ CRITICAL — Recursion guard (read first):
---
## Required: Load Trellis Context First
This platform does NOT auto-inject task context via hook. Before doing anything else, you MUST load context yourself.
### Step 1: Find the active task path
Try in order — stop at the first one that yields a task path:
1. **Look at the dispatch prompt** you received from the main agent. If its first line is `Active task: <path>` (e.g. `Active task: .trellis/tasks/04-17-foo`), use that path. The main agent is required to include this line on class-2 platforms.
2. **Run** `python3 ./.trellis/scripts/task.py current --source` and read the `Current task:` line.
3. **If both fail** (no `Active task:` line in the prompt and `task.py current` returns no task), ask the user which task to work on; do NOT guess.
### Step 2: Load task context from the resolved path
1. Read the task's `prd.md` (requirements) and `info.md` if it exists (technical design).
2. Read `<task-path>/implement.jsonl` — JSONL list of dev spec files relevant to this agent.
3. For each entry in the JSONL, Read its `file` path — these are the dev specs you must follow.
**Skip rows without a `"file"` field** (e.g. `{"_example": "..."}` seed rows left over from `task.py create` before the curator ran).
If `implement.jsonl` has no curated entries (only a seed row, or the file is missing), fall back to: read `prd.md`, list available specs with `python3 ./.trellis/scripts/get_context.py --mode packages`, and pick the specs that match the task domain yourself. Do NOT block on the missing jsonl — proceed with prd-only context plus your spec judgment.
If the resolved task path has no `prd.md`, ask the user what to work on; do NOT proceed without context.
---
You are the Trellis implementer agent.
Trellis Context Loading Protocol:
- Look for the `<!-- trellis-hook-injected -->` marker in your input above.
- If the marker is present, the native `SubagentStart` hook has already loaded the role-specific task artifacts and spec context. Proceed directly with the implementation work.
- If the marker is absent, find `Active task: <path>` in your dispatch prompt. Read `<path>/implement.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before doing the work. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
Rules:
- Read before write. Follow `.trellis/spec/` guidance relevant to the task.
- Keep changes focused on the requested scope.
@@ -76,15 +33,3 @@ Before finishing, summarize:
- Tests/checks run
- Remaining risks or follow-ups
"""
# Disable Codex collab tools entirely for this sub-agent. With both
# multi_agent and multi_agent_v2 off, `spawn_agent` / `wait_agent` /
# `list_agents` / `close_agent` are not registered in the sub-agent's tool
# list at all — the model literally cannot call them. This is the structural
# fix for the wait_agent self-deadlock when the parent inherits its
# transcript via Codex's default `fork_turns="all"` (#240 follow-up, #241).
[features]
multi_agent = false
[features.multi_agent_v2]
enabled = false
+15 -15
查看文件
@@ -1,6 +1,8 @@
name = "trellis-research"
description = "Trellis researcher for specs, code patterns, and affected files. Writes findings into {TASK_DIR}/research/ — read-only elsewhere."
sandbox_mode = "workspace-write"
# model = "gpt-5.6-terra"
# model_reasoning_effort = "high"
developer_instructions = """
You are the Trellis researcher agent.
@@ -11,11 +13,21 @@ Conversations get compacted; files don't. Every research topic MUST be
persisted to `{TASK_DIR}/research/<topic>.md`. Returning findings only
through the chat reply is a failure.
## Trellis Context Loading Protocol
Look for the `<!-- trellis-hook-injected -->` marker in your input above.
- If the marker is present, the native `SubagentStart` hook has supplied the
`Active task: <path>` header and research-only context. Use that task path.
- If the marker is absent, find `Active task: <path>` in your dispatch prompt.
If it is absent too, ask the main session for the path; do not run
`task.py current`, guess, or use another session's task.
- Do not load `implement.jsonl` or `check.jsonl`; research is role-isolated.
## Workflow
1. Run `python3 ./.trellis/scripts/task.py current --source` to get the
active task path and source. If no active task is set, ask the user
where to write output; do not guess.
1. Use the active task path supplied by the context-loading protocol. If no
path is supplied, ask the user where to write output; do not guess.
2. Run `mkdir -p <TASK_DIR>/research` to ensure the directory exists.
3. Read `.trellis/workflow.md`, relevant `.trellis/spec/` files, and
target code before forming an opinion.
@@ -59,15 +71,3 @@ If the user asks you to edit code, decline and tell them to spawn the
...
```
"""
# Disable Codex collab tools entirely for this sub-agent. With both
# multi_agent and multi_agent_v2 off, `spawn_agent` / `wait_agent` /
# `list_agents` / `close_agent` are not registered in the sub-agent's tool
# list at all — the model literally cannot call them. This is the structural
# fix for the wait_agent self-deadlock when the parent inherits its
# transcript via Codex's default `fork_turns="all"` (#240 follow-up, #241).
[features]
multi_agent = false
[features.multi_agent_v2]
enabled = false
+11
查看文件
@@ -26,3 +26,14 @@ project_doc_fallback_filenames = ["AGENTS.md"]
# aborts the entire config load and blocks Codex from starting. Codex's own
# default for multi_agent_v2 is used instead; tune it in your user-level
# config if needed.
# Pin the subagent recursion depth explicitly instead of relying on Codex's
# default. #445 removed the per-agent `[features] multi_agent = false` guard
# (the #240/#241 wait_agent-deadlock structural fix) because native subagent
# dispatch already caps recursion via `agents.max_depth` — but that key is
# global/user-level, not settable inside an individual agent's .toml. Pinning
# it here means an upstream default change, or a user's own global override,
# can't silently reopen the recursion the #240/#241 fix closed. Project config
# (this file) takes precedence over user-level `~/.codex/config.toml`.
[agents]
max_depth = 1
+12
查看文件
@@ -10,6 +10,18 @@
}
]
}
],
"SubagentStart": [
{
"matcher": "^(?:trellis-implement|trellis-check|trellis-research)$",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/inject-subagent-context.py",
"timeout": 15
}
]
}
]
}
}
+1133
查看文件
文件差异内容过多而无法显示 加载差异
普通文件 -> 可执行文件
+155 -77
查看文件
@@ -18,9 +18,11 @@ missing or a tag is absent, the breadcrumb degrades to a generic
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 is not wired (no per-turn
hook entry point). Written to each platform's hooks directory via
writeSharedHooks() at init time.
CodeBuddy, Droid, Gemini, Copilot, Kiro). 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.
Silent exit 0 cases (no output):
- No .trellis/ directory found (not a Trellis project)
@@ -32,6 +34,8 @@ import json
import os
import re
import sys
import queue
import threading
from pathlib import Path
# Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is
@@ -49,53 +53,20 @@ if sys.platform.startswith("win"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
except Exception:
pass
pass # Optional Windows stream setup; keep hook startup non-fatal.
elif hasattr(_stream, "detach"):
try:
setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
except Exception:
pass
pass # Optional Windows stream setup; keep hook startup non-fatal.
from typing import Optional
CODEX_SUB_AGENT_NOTICE = """<sub-agent-notice>
SUB-AGENT NOTICE - READ FIRST IF SPAWNED VIA spawn_agent
If your parent session spawned you via spawn_agent with an explicit task
message above this hook output, that message is your only job.
- Execute the parent message exactly as written, then return.
- Ignore all Trellis workflow guidance below this notice.
- Do NOT call task.py start, task.py add-context, or task.py archive.
- Do NOT call wait_agent or spawn_agent.
- Do NOT modify .trellis/tasks/* or any other file unless the parent message
explicitly asks for that.
If you are the main interactive Codex session and the user is typing at the
terminal with no parent agent, use the workflow guidance below normally.
</sub-agent-notice>"""
# Bootstrap notice for Codex while the session has no active task. Replaces the
# heavyweight SessionStart context injection — instead of pushing 9.5 KB of
# workflow text up front, we just nudge the AI to read the `trellis-start` skill once.
# The nudge keeps showing up while status == "no_task" (cheap text, AI won't
# re-read after the first time). Once a task is created the breadcrumb status
# flips and this notice stops appearing automatically. Sub-agents are warded
# off by the <sub-agent-notice> above plus the explicit exemption below.
# Bootstrap notice for Codex while the session has no active task. Codex does not
# get the full SessionStart overview; this short reminder points the main session
# at the start skill once and leaves the per-turn state block compact.
CODEX_NO_TASK_BOOTSTRAP_NOTICE = """<trellis-bootstrap>
You are running in a Trellis-managed Codex session and there is no active task yet.
If you have not already loaded Trellis context this session, read the `trellis-start` skill once:
$trellis-start
(equivalent to reading `.agents/skills/trellis-start/SKILL.md` and following its Steps 1-3)
The skill walks you through workflow.md, dev profile, git status, active tasks, and spec
indexes. Then route the user's request per the <workflow-state> A/B/C rules below.
Sub-agent exemption: if you are a sub-agent (spawned via spawn_agent with a parent task
message), DO NOT read `$trellis-start`. Execute the parent message directly as instructed by the
<sub-agent-notice> above.
If you have not already loaded Trellis context this session, read the `trellis-start` skill once.
</trellis-bootstrap>"""
@@ -125,6 +96,9 @@ def _detect_platform(input_data: dict) -> str | None:
if isinstance(input_data.get("cursor_version"), str):
return "cursor"
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",
@@ -133,6 +107,7 @@ def _detect_platform(input_data: dict) -> str | None:
"QODER_PROJECT_DIR": "qoder",
"KIRO_PROJECT_DIR": "kiro",
"COPILOT_PROJECT_DIR": "copilot",
"TRAE_PROJECT_DIR": "trae",
}
for env_name, platform in env_map.items():
if os.environ.get(env_name):
@@ -154,6 +129,10 @@ def _detect_platform(input_data: dict) -> str | None:
return "droid"
if ".kiro" in script_parts:
return "kiro"
if ".trae" in script_parts:
return "trae"
if ".zcode" in script_parts:
return "zcode"
return None
@@ -249,25 +228,89 @@ def _read_trellis_config(root: Path) -> dict:
return {}
DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"
def _resolve_skip_keyword(config: dict) -> str:
"""Read `prompt_injection.skip_keyword` from parsed .trellis/config.yaml.
Mirrors `common.config.get_prompt_injection_config()`. Defaults to
"no-trellis"; "" disables the escape hatch entirely. A non-string value
falls back to the default.
"""
if isinstance(config, dict):
section = config.get("prompt_injection")
if isinstance(section, dict):
raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD)
if isinstance(raw, str):
return raw
return DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD
def prompt_has_skip_keyword(prompt: str, keyword: str) -> bool:
"""Case-insensitive, word-boundary match of `keyword` in `prompt`.
Hyphen counts as a word char so "no-trellisx" / "xno-trellis" /
"foo-no-trellis" don't match, but punctuation/whitespace boundaries do.
Empty keyword never matches (disables the escape hatch).
"""
if not keyword or not isinstance(prompt, str):
return False
pattern = r"(?<![\w-])" + re.escape(keyword) + r"(?![\w-])"
return re.search(pattern, prompt, re.IGNORECASE) is not None
def _resolve_codex_dispatch_mode(config: dict) -> str:
"""Normalize `codex.dispatch_mode` from .trellis/config.yaml to "auto" or "inline".
Defaults to `auto`. The legacy `sub-agent` value is an alias for `auto`.
Any other explicit value (including invalid ones) falls back to `inline`
without per-turn warnings. Shared by `_codex_mode_banner` (the per-turn
banner) and `resolve_breadcrumb_key` (the breadcrumb tag key) so the two
stay in lockstep.
"""
mode = "auto"
if isinstance(config, dict):
codex_cfg = config.get("codex")
if isinstance(codex_cfg, dict):
cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower()
if cfg_mode == "inline":
mode = "inline"
elif cfg_mode in ("auto", "sub-agent"):
mode = "auto"
else:
mode = "inline"
return mode
def _codex_mode_banner(config: dict) -> str:
"""Emit a `<codex-mode>` banner for the additionalContext payload.
Reads `codex.dispatch_mode` from .trellis/config.yaml; defaults to
`inline` when missing or invalid because Codex sub-agents run with
`fork_turns="none"` isolation and can't inherit the parent session's
task context. The banner makes the active mode explicit to Codex AI
per turn, complementing the workflow-state body which is per-status.
Mode tells AI which dispatch protocol to follow; workflow-state tells
AI what step it's at.
`auto`, which dispatches Trellis sub-agents using native Codex context
injection with a child-side fallback. This does not rely on inherited
parent transcripts: `fork_turns` remains caller-controlled, and
fresh-history sub-agents still receive their explicit delegated task and
inherited session configuration. `inline` is an explicit opt-out; the
legacy `sub-agent` value is an alias for `auto`. Invalid explicit values
fall back to `inline` without per-turn warnings. The banner makes the
active mode explicit to Codex AI per turn, complementing the workflow-state
body which is per-status. Mode tells AI which dispatch protocol to follow;
workflow-state tells AI what step it's at.
"""
mode = "inline"
if isinstance(config, dict):
codex_cfg = config.get("codex")
if isinstance(codex_cfg, dict):
cfg_mode = codex_cfg.get("dispatch_mode")
if cfg_mode in ("inline", "sub-agent"):
mode = cfg_mode
return f"<codex-mode>{mode}</codex-mode>"
mode = _resolve_codex_dispatch_mode(config)
if mode == "auto":
meaning = (
"auto: implement/check work defaults to Trellis sub-agents; native Codex "
"context injection is preferred and child-side loading is the fallback. "
"The main session still coordinates, clarifies, updates specs, commits, and finishes."
)
else:
meaning = (
"inline: the main session implements/checks directly; "
"do not dispatch implement/check sub-agents."
)
return f"<codex-mode>{meaning}</codex-mode>"
def resolve_breadcrumb_key(
@@ -275,22 +318,17 @@ def resolve_breadcrumb_key(
) -> str:
"""Pick the breadcrumb tag key based on Codex dispatch_mode.
Codex defaults to ``inline`` because sub-agents run with ``fork_turns="none"``
isolation and can't inherit the parent session's task context. Users can
opt into ``codex.dispatch_mode: sub-agent`` in ``.trellis/config.yaml``
to use the parallel ``<status>-inline`` tag → ``<status>`` flip. Invalid
or missing values fall back to inline.
Codex defaults to ``auto`` and therefore uses the ordinary ``<status>``
breadcrumb for native SubagentStart dispatch with child-side fallback;
it does not depend on an inherited parent transcript. ``inline`` selects
the parallel ``<status>-inline`` tag; ``sub-agent`` remains an alias for
``auto``. Invalid explicit values fall back to inline without per-turn
warnings.
Non-codex platforms return the plain status unchanged.
"""
if platform == "codex":
mode = "inline"
if isinstance(config, dict):
codex_cfg = config.get("codex")
if isinstance(codex_cfg, dict):
cfg_mode = codex_cfg.get("dispatch_mode")
if cfg_mode in ("inline", "sub-agent"):
mode = cfg_mode
mode = _resolve_codex_dispatch_mode(config)
return f"{status}-inline" if mode == "inline" else status
return status
@@ -316,8 +354,6 @@ def build_breadcrumb(
if body is None:
body = "Refer to workflow.md for current step."
header = f"Status: {status}" if task_id is None else f"Task: {task_id} ({status})"
if source:
header = f"{header}\nSource: {source}"
return f"<workflow-state>\n{header}\n{body}\n</workflow-state>"
@@ -325,14 +361,44 @@ def build_breadcrumb(
# Entry
# ---------------------------------------------------------------------------
def _load_hook_input() -> dict:
"""Read hook JSON without trusting host runners to close stdin.
Kiro IDE `runCommand` and similar hook runners can leave stdin open while
sending no payload. A plain `json.load(sys.stdin)` then blocks forever.
Normal hook runners write the complete JSON payload and close stdin, so the
short daemon read preserves that path while failing closed to `{}` for
non-piping hosts.
"""
result_queue: "queue.Queue[str | Exception]" = queue.Queue(maxsize=1)
def _read() -> None:
try:
result_queue.put(sys.stdin.read())
except Exception as exc:
result_queue.put(exc)
reader = threading.Thread(target=_read, daemon=True)
reader.start()
try:
raw = result_queue.get(timeout=0.2)
except queue.Empty:
return {}
if isinstance(raw, Exception):
return {}
try:
data = json.loads(raw) if raw.strip() else {}
except (json.JSONDecodeError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def main() -> int:
if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1":
return 0
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
data = {}
data = _load_hook_input()
cwd_str = data.get("cwd") or os.getcwd()
cwd = Path(cwd_str)
@@ -341,9 +407,12 @@ def main() -> int:
if root is None:
return 0 # not a Trellis project
config = _read_trellis_config(root)
if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
return 0 # user opted out of the per-turn breadcrumb for this turn
templates = load_breadcrumbs(root)
platform = _detect_platform(data)
config = _read_trellis_config(root)
task = get_active_task(root, data)
if task is None:
# No active task — still emit a breadcrumb nudging AI toward
@@ -355,17 +424,26 @@ def main() -> int:
else:
task_id, status, source = task
status_key = resolve_breadcrumb_key(status, platform, config)
source_for_breadcrumb = None if platform == "codex" else source
breadcrumb = build_breadcrumb(
task_id, status, templates, source, breadcrumb_key=status_key
task_id, status, templates, source_for_breadcrumb, breadcrumb_key=status_key
)
if platform == "codex":
parts: list[str] = [CODEX_SUB_AGENT_NOTICE]
parts: list[str] = []
if task is None:
parts.append(CODEX_NO_TASK_BOOTSTRAP_NOTICE)
parts.append(_codex_mode_banner(config))
parts.append(breadcrumb)
breadcrumb = "\n\n".join(parts)
# Kiro (CLI userPromptSubmit / IDE promptSubmit) adds a hook's stdout
# directly to the conversation context — no JSON envelope. Emit the bare
# breadcrumb text. Conditionally isolated: all other platforms keep the
# hookSpecificOutput JSON path below unchanged.
if platform == "kiro":
print(breadcrumb)
return 0
# Gemini CLI 0.40.x rejects "UserPromptSubmit" — its per-turn event is
# named "BeforeAgent". Other platforms (Claude/Cursor/Qoder/CodeBuddy/
# Droid/Codex/Copilot) accept the original Claude-style name.
普通文件 -> 可执行文件
+195 -125
查看文件
@@ -33,12 +33,12 @@ if sys.platform.startswith("win"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
except Exception:
pass
pass # Optional Windows stream setup; keep hook startup non-fatal.
elif hasattr(_stream, "detach"):
try:
setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
except Exception:
pass
pass # Optional Windows stream setup; keep hook startup non-fatal.
def _normalize_windows_shell_path(path_str: str) -> str:
@@ -92,27 +92,16 @@ def _normalize_windows_shell_path(path_str: str) -> str:
warnings.filterwarnings("ignore")
FIRST_REPLY_NOTICE = """<first-reply-notice>
On the first visible assistant reply in this session, begin with exactly one short Chinese sentence:
Trellis SessionStart 已注入:workflow、当前任务状态、开发者身份、git 状态、active tasks、spec 索引已加载。
Then continue directly with the user's request. This notice is one-shot: do not repeat it after the first assistant reply in the same session.
On the first visible assistant reply in this session, briefly acknowledge that Trellis SessionStart context loaded.
Choose the acknowledgment language in this order:
1. Use the language of the user's current request (the user message that triggered this reply).
2. If that request has no clear natural language, use an explicitly established project communication language.
3. If neither provides a language, output the language-neutral fallback exactly: `Trellis SessionStart ✓`.
Continue directly with the user's request after the acknowledgment.
The acknowledgment must not alter the language used for the remainder of the response.
This notice is one-shot: do not repeat it after the first visible assistant reply in this session.
</first-reply-notice>"""
SUB_AGENT_NOTICE = """<sub-agent-notice>
SUB-AGENT NOTICE - READ FIRST IF SPAWNED VIA spawn_agent
If your parent session spawned you via spawn_agent with an explicit task
message above this hook output, that message is your only job.
- Execute the parent message exactly as written, then return.
- Ignore all Trellis workflow guidance below this notice.
- Do NOT call task.py start, task.py add-context, or task.py archive.
- Do NOT call wait_agent or spawn_agent.
- Do NOT modify .trellis/tasks/* or any other file unless the parent message
explicitly asks for that.
If you are the main interactive Codex session and the user is typing at the
terminal with no parent agent, use the workflow guidance below normally.
</sub-agent-notice>"""
def should_skip_injection() -> bool:
if os.environ.get("TRELLIS_HOOKS") == "0":
@@ -133,7 +122,7 @@ def configure_project_encoding(project_dir: Path) -> None:
configure_encoding()
except Exception:
pass
pass # Optional encoding helper; host defaults are still usable.
def _has_curated_jsonl_entry(jsonl_path: Path) -> bool:
@@ -240,12 +229,19 @@ def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path:
def _get_task_status(trellis_dir: Path, hook_input: dict) -> str:
active = _resolve_active_task(trellis_dir, hook_input)
if not active.task_path:
return f"Status: NO ACTIVE TASK\nSource: {active.source}\nNext: Describe what you want to work on"
return (
"Status: NO ACTIVE TASK\n"
"Next: Classify the current turn and ask for task-creation consent "
"before creating any Trellis task."
)
task_ref = active.task_path
task_dir = _resolve_task_dir(trellis_dir, task_ref)
if active.stale or not task_dir.is_dir():
return f"Status: STALE POINTER\nTask: {task_ref}\nSource: {active.source}\nNext: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish"
return (
f"Status: STALE POINTER\nTask: {task_ref}\n"
"Next: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish"
)
task_json_path = task_dir / "task.json"
task_data: dict = {}
@@ -253,46 +249,176 @@ def _get_task_status(trellis_dir: Path, hook_input: dict) -> str:
try:
task_data = json.loads(task_json_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, PermissionError):
pass
pass # Optional task metadata; fall back to generic status.
task_title = task_data.get("title", task_ref)
task_status = task_data.get("status", "unknown")
if task_status == "completed":
return f"Status: COMPLETED\nTask: {task_title}\nSource: {active.source}\nNext: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` or start a new task"
has_context = False
for jsonl_name in ("implement.jsonl", "check.jsonl", "spec.jsonl"):
jsonl_path = task_dir / jsonl_name
if jsonl_path.is_file() and _has_curated_jsonl_entry(jsonl_path):
has_context = True
break
return (
f"Status: COMPLETED\nTask: {task_title}\n"
f"Next: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` "
"or start a new task."
)
has_prd = (task_dir / "prd.md").is_file()
has_design = (task_dir / "design.md").is_file()
has_implement = (task_dir / "implement.md").is_file()
present = [
name
for name in ("prd.md", "design.md", "implement.md", "implement.jsonl", "check.jsonl")
if (task_dir / name).is_file()
]
present_line = ", ".join(present) if present else "none"
if not has_prd:
return f"Status: NOT READY\nTask: {task_title}\nSource: {active.source}\nMissing: prd.md not created\nNext: Write PRD (see workflow.md Phase 1.1) then curate implement.jsonl per Phase 1.3"
return (
f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n"
"Next: Load trellis-brainstorm and write prd.md. Stay in planning."
)
if not has_context:
return f"Status: NOT READY\nTask: {task_title}\nSource: {active.source}\nMissing: implement.jsonl / check.jsonl missing or empty\nNext: Curate entries per workflow.md Phase 1.3 (spec + research files only), then `task.py start`"
if task_status == "planning":
if has_design and has_implement:
next_action = "Review planning artifacts with the user before `task.py start`."
else:
next_action = (
"Lightweight task can ask for start review with PRD-only; "
"complex task must add design.md and implement.md before `task.py start`."
)
return (
f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n"
f"Next: {next_action}"
)
return (
f"Status: READY\nTask: {task_title}\n"
f"Source: {active.source}\n"
"Next required action: dispatch `trellis-implement` per Phase 2.1. "
"For agent-capable platforms, the default is to NOT edit code in the main session. "
"After implementation, dispatch `trellis-check` per Phase 2.2 before reporting completion.\n"
"Sub-agent self-exemption: if you are reading this as a `trellis-implement` or "
"`trellis-check` sub-agent (your own role / agent name reflects that), this dispatch "
"instruction does NOT apply to you — you are already the dispatched sub-agent. "
"Implement / check directly without spawning another sub-agent of the same kind.\n"
"User override (per-turn escape hatch): if the user's CURRENT message explicitly tells the "
"main session to handle it directly (\"你直接改\" / \"别派 sub-agent\" / \"main session 写就行\" / "
"\"do it inline\" / \"不用 sub-agent\"), honor it for this turn and edit code directly. "
"Per-turn only; do NOT invent an override the user did not say."
f"Status: {task_status.upper()}\nTask: {task_title}\nPresent: {present_line}\n"
"Next: Follow the matching per-turn workflow-state. Context order is jsonl entries, "
"prd.md, design.md if present, implement.md if present."
)
def _run_git(repo_root: Path, args: list[str]) -> str:
try:
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=3,
cwd=str(repo_root),
)
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError):
return ""
if result.returncode != 0:
return ""
return result.stdout.strip()
def _format_git_state(repo_root: Path) -> str:
branch = _run_git(repo_root, ["branch", "--show-current"]) or "(detached)"
dirty_lines = [
line for line in _run_git(repo_root, ["status", "--porcelain"]).splitlines()
if line.strip()
]
dirty_text = "clean" if not dirty_lines else f"dirty {len(dirty_lines)} paths"
return f"Git: branch {branch}; {dirty_text}."
def _repo_relative(repo_root: Path, path: Path) -> str:
try:
return path.relative_to(repo_root).as_posix()
except ValueError:
return str(path)
def _collect_spec_index_paths(trellis_dir: Path) -> list[str]:
paths: list[str] = []
guides_index = trellis_dir / "spec" / "guides" / "index.md"
if guides_index.is_file():
paths.append(".trellis/spec/guides/index.md")
spec_dir = trellis_dir / "spec"
if not spec_dir.is_dir():
return paths
for sub in sorted(spec_dir.iterdir()):
if not sub.is_dir() or sub.name.startswith(".") or sub.name == "guides":
continue
index_file = sub / "index.md"
if index_file.is_file():
paths.append(f".trellis/spec/{sub.name}/index.md")
continue
for nested in sorted(sub.iterdir()):
if not nested.is_dir():
continue
nested_index = nested / "index.md"
if nested_index.is_file():
paths.append(f".trellis/spec/{sub.name}/{nested.name}/index.md")
return paths
def _build_compact_current_state(
trellis_dir: Path,
hook_input: dict,
spec_index_paths: list[str],
) -> str:
repo_root = trellis_dir.parent
lines: list[str] = []
try:
from common.paths import get_active_journal_file, get_developer, get_tasks_dir, count_lines # type: ignore[import-not-found]
from common.tasks import iter_active_tasks # type: ignore[import-not-found]
except Exception:
get_active_journal_file = None # type: ignore[assignment]
get_developer = None # type: ignore[assignment]
get_tasks_dir = None # type: ignore[assignment]
count_lines = None # type: ignore[assignment]
iter_active_tasks = None # type: ignore[assignment]
developer = get_developer(repo_root) if get_developer else None
lines.append(f"Developer: {developer or '(not initialized)'}")
lines.append(_format_git_state(repo_root))
active = _resolve_active_task(trellis_dir, hook_input)
if active.task_path:
task_dir = _resolve_task_dir(trellis_dir, active.task_path)
status = "unknown"
task_json = task_dir / "task.json"
if task_json.is_file():
try:
data = json.loads(task_json.read_text(encoding="utf-8"))
if isinstance(data, dict):
status = str(data.get("status") or "unknown")
except (json.JSONDecodeError, OSError):
pass # Optional task metadata; fall back to generic status.
lines.append(f"Current task: {_repo_relative(repo_root, task_dir)}; status={status}.")
else:
lines.append("Current task: none.")
if get_tasks_dir and iter_active_tasks:
try:
task_count = sum(1 for _ in iter_active_tasks(get_tasks_dir(repo_root)))
lines.append(
f"Active tasks: {task_count} total. Use `python3 ./.trellis/scripts/task.py list --mine` only if needed."
)
except Exception:
pass # Optional task summary; keep compact state available.
if get_active_journal_file and count_lines:
journal = get_active_journal_file(repo_root)
if journal:
lines.append(
f"Journal: {_repo_relative(repo_root, journal)}, {count_lines(journal)} / 2000 lines."
)
if spec_index_paths:
lines.append(f"Spec indexes: {len(spec_index_paths)} available.")
return "\n".join(lines)
def _extract_range(content: str, start_header: str, end_header: str) -> str:
"""Extract lines starting at `## start_header` up to (but excluding) `## end_header`."""
lines = content.splitlines()
@@ -320,33 +446,25 @@ _BREADCRUMB_TAG_RE = re.compile(
def _strip_breadcrumb_tag_blocks(content: str) -> str:
return _BREADCRUMB_TAG_RE.sub("", content)
stripped = _BREADCRUMB_TAG_RE.sub("", content)
stripped = re.sub(r"<!--.*?-->", "", stripped, flags=re.DOTALL)
stripped = re.sub(r"^\[(?!/?workflow-state:)/?[^\]\n]+\]\s*\n?", "", stripped, flags=re.MULTILINE)
return re.sub(r"\n{3,}", "\n\n", stripped).strip()
def _build_workflow_toc(workflow_path: Path) -> str:
"""Inject workflow guide: TOC + Phase Index + Phase 1/2/3 step details.
Since v0.5.0-rc.0 the [workflow-state:STATUS] breadcrumb tag blocks
live inside ## Phase Index. They're consumed by inject-workflow-state.py
on each UserPromptSubmit, so strip them from the session-start payload
to avoid duplicating context.
"""
"""Inject only the compact Phase Index summary for SessionStart."""
content = read_file(workflow_path)
if not content:
return "No workflow.md found"
out_lines = [
"# Development Workflow — Section Index",
"Full guide: .trellis/workflow.md (read on demand)",
"# Development Workflow - Session Summary",
"Full guide: .trellis/workflow.md. Step detail: `python3 ./.trellis/scripts/get_context.py --mode phase --step <X.Y>`.",
"",
"## Table of Contents",
]
for line in content.splitlines():
if line.startswith("## "):
out_lines.append(line)
out_lines += ["", "---", ""]
phases = _extract_range(content, "Phase Index", "Customizing Trellis (for forks)")
phases = _extract_range(content, "Phase Index", "Phase 1: Plan")
if phases:
out_lines.append(_strip_breadcrumb_tag_blocks(phases).rstrip())
@@ -370,16 +488,12 @@ def main() -> None:
configure_project_encoding(project_dir)
trellis_dir = project_dir / ".trellis"
context_key = _resolve_context_key(project_dir, hook_input)
spec_index_paths = _collect_spec_index_paths(trellis_dir)
output = StringIO()
output.write(SUB_AGENT_NOTICE)
output.write("\n\n")
output.write("""<session-context>
You are starting a new session in a Trellis-managed project.
Read and follow all instructions below carefully.
Trellis compact SessionStart context. Use it to orient the session; load details on demand.
</session-context>
""")
@@ -387,65 +501,23 @@ Read and follow all instructions below carefully.
output.write("\n\n")
output.write("<current-state>\n")
context_script = trellis_dir / "scripts" / "get_context.py"
output.write(run_script(context_script, context_key))
output.write(_build_compact_current_state(trellis_dir, hook_input, spec_index_paths))
output.write("\n</current-state>\n\n")
output.write("<workflow>\n")
output.write("<trellis-workflow>\n")
output.write(_build_workflow_toc(trellis_dir / "workflow.md"))
output.write("\n</workflow>\n\n")
output.write("\n</trellis-workflow>\n\n")
output.write("<guidelines>\n")
output.write(
"Project spec indexes are listed by path below. Each index contains a "
"**Pre-Development Checklist** listing the specific guideline files to "
"read before coding.\n\n"
"- If you're spawning an implement/check sub-agent, context is injected "
"automatically via `{task}/implement.jsonl` / `check.jsonl`. You do NOT "
"need to read these indexes yourself.\n"
"- For agent-capable platforms, the default is to dispatch "
"`trellis-implement` and `trellis-check` (so JSONL context is loaded by "
"the sub-agents) rather than editing code in the main session. "
"Honor a per-turn user override only if the user's current message "
"explicitly opts out (see <task-status> below for override phrases).\n"
"- Sub-agent self-exemption: if you are reading this as a `trellis-implement` "
"or `trellis-check` sub-agent, the \"dispatch trellis-implement / trellis-check\" "
"rule above does NOT apply to you — you are already the dispatched sub-agent. "
"Do NOT spawn another sub-agent of the same kind; implement / check directly.\n\n"
"Task context order for implementation/check: jsonl entries -> `prd.md` -> "
"`design.md if present` -> `implement.md if present`. Missing optional artifacts "
"are skipped for lightweight tasks.\n\n"
)
# guides/ inlined (cross-package thinking, broadly useful)
guides_index = trellis_dir / "spec" / "guides" / "index.md"
if guides_index.is_file():
output.write("## guides (inlined — cross-package thinking guides)\n")
output.write(read_file(guides_index))
output.write("\n\n")
# Other indexes — paths only
paths: list[str] = []
spec_dir = trellis_dir / "spec"
if spec_dir.is_dir():
for sub in sorted(spec_dir.iterdir()):
if not sub.is_dir() or sub.name.startswith("."):
continue
if sub.name == "guides":
continue
index_file = sub / "index.md"
if index_file.is_file():
paths.append(f".trellis/spec/{sub.name}/index.md")
else:
for nested in sorted(sub.iterdir()):
if not nested.is_dir():
continue
nested_index = nested / "index.md"
if nested_index.is_file():
paths.append(
f".trellis/spec/{sub.name}/{nested.name}/index.md"
)
if paths:
output.write("## Available spec indexes (read on demand)\n")
for p in paths:
if spec_index_paths:
output.write("## Available indexes (read on demand)\n")
for p in spec_index_paths:
output.write(f"- {p}\n")
output.write("\n")
@@ -459,9 +531,7 @@ Read and follow all instructions below carefully.
output.write(f"<task-status>\n{task_status}\n</task-status>\n\n")
output.write("""<ready>
Context loaded. Workflow index, project state, and guidelines are already injected above — do NOT re-read them.
When the user sends the first message, follow <task-status> and the workflow guide.
If a task is READY, execute its Next required action without asking whether to continue.
Context loaded. Follow <task-status>. Load workflow/spec/task details only when needed.
</ready>""")
context = output.getvalue()