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

这个提交包含在:
2026-09-06 11:14:41 +08:00
父节点 ab82fe30c0
当前提交 c3cef5cfc4
共修改 48 个文件,包含 4588 行新增和 1053 行删除
+186 -22
查看文件
@@ -9,10 +9,13 @@ Provides:
Note:
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
are now seeded at ``task.py create`` time with a self-describing
``_example`` line; the AI agent curates real entries during planning when
the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the
current planning artifact contract.
are created empty at ``task.py create`` time; the AI agent curates real
entries during planning when the task needs sub-agent/spec context. See
``.trellis/workflow.md`` for the current planning artifact contract.
Older Trellis versions seeded those files with a ``{"_example": ...}``
placeholder row. Sub-agent validation rejects that row; explicit inline
mode accepts historical seed-only rows without counting them as context.
"""
from __future__ import annotations
@@ -21,11 +24,11 @@ import argparse
import json
from pathlib import Path
from .config import get_context_injection_limits
from .config import get_codex_dispatch_mode, get_context_injection_limits
from .git import branch_exists_locally
from .io import read_json
from .log import Colors, colored
from .paths import FILE_TASK_JSON, get_repo_root
from .paths import DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
from .task_utils import resolve_task_dir
# Extensions that look like code rather than spec/research docs. Entries with
@@ -60,15 +63,26 @@ def cmd_add_context(args: argparse.Namespace) -> int:
"""Add entry to JSONL context file."""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if target_dir is None:
return 1
jsonl_name = args.file
path = args.path
reason = args.reason or "Added manually"
if not target_dir.is_dir():
if not target_dir or not target_dir.is_dir():
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
return 1
# The JSONL name is user input joined onto the task dir — keep it a plain
# filename so it cannot create files elsewhere.
if "/" in jsonl_name or "\\" in jsonl_name or jsonl_name in (".", ".."):
print(colored(
f"Error: context file must be a plain name (e.g. implement, check): {jsonl_name}",
Colors.RED,
))
return 1
# Support shorthand
if not jsonl_name.endswith(".jsonl"):
jsonl_name = f"{jsonl_name}.jsonl"
@@ -110,12 +124,41 @@ def cmd_add_context(args: argparse.Namespace) -> int:
# Command: validate
# =============================================================================
def curated_entry_count(jsonl_file: Path) -> int | None:
"""Count curated entries in a jsonl context manifest.
Returns None when the file does not exist — `task.py create` seeds the
manifests only on sub-agent-capable platforms, so an absent file means no
sub-agent will ever read it and callers should not gate on it. A curated
entry is a JSON object row carrying a truthy ``file`` (or legacy ``path``)
value: the same rows the sub-agent injection hook materializes.
"""
if not jsonl_file.is_file():
return None
try:
lines = jsonl_file.read_text(encoding="utf-8").splitlines()
except OSError:
return 0
count = 0
for line in lines:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(data, dict) and (data.get("file") or data.get("path")):
count += 1
return count
def cmd_validate(args: argparse.Namespace) -> int:
"""Validate JSONL context files."""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if not target_dir.is_dir():
if target_dir is None or not target_dir.is_dir():
print(colored("Error: task directory required", Colors.RED))
return 1
@@ -170,11 +213,69 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
return False
def _resolve_context_entry_path(
file_path: str, repo_root: Path, task_dir: Path | None
) -> Path | None:
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
Exact historical self-references are remapped only for archived tasks.
``None`` means the remapped path traversed or resolved outside that archive.
"""
repo_path = repo_root / file_path
try:
repo_path.resolve().relative_to(repo_root.resolve())
except (OSError, RuntimeError, ValueError):
return None
if task_dir is None:
return repo_path
try:
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
except ValueError:
return repo_path
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
return repo_path
year_month = task_parts[3]
if (
len(year_month) != 7
or year_month[4] != "-"
or not year_month[:4].isdigit()
or not year_month[5:].isdigit()
):
return repo_path
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
posix_path = file_path.replace("\\", "/")
if posix_path == historical_root:
relative_parts: tuple[str, ...] = ()
elif posix_path.startswith(f"{historical_root}/"):
relative_path = posix_path[len(historical_root) + 1 :]
if relative_path.endswith("/"):
relative_path = relative_path[:-1]
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
if any(part in ("", ".", "..") for part in relative_parts):
return None
else:
return repo_path
try:
archive_root = task_dir.resolve()
resolved_path = task_dir.joinpath(*relative_parts).resolve()
resolved_path.relative_to(archive_root)
except (OSError, RuntimeError, ValueError):
return None
return resolved_path
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
"""Validate a single JSONL file.
Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are
skipped silently; they are self-describing comments, not real entries.
Historical seed-only rows are accepted in explicit inline mode. Sub-agent
mode rejects scaffolding and empty manifests. Real entries are validated
in both modes.
Beyond hard errors (missing file/dir, invalid JSON), this also prints
non-blocking hygiene warnings (never counted in ``errors``, never change
@@ -184,6 +285,7 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
"""
file_name = jsonl_file.name
errors = 0
inline = get_codex_dispatch_mode(repo_root) == "inline"
if not jsonl_file.is_file():
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
@@ -212,22 +314,59 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
errors += 1
continue
if not isinstance(data, dict):
print(
f" {colored(f'{file_name}:{line_num}: Expected a JSON object', Colors.RED)}"
)
errors += 1
continue
if inline and set(data) == {"_example"}:
continue
if "_example" in data:
error_message = (
f"{file_name}:{line_num}: Placeholder `_example` row left by an older "
"task.py create — delete this line, or replace it with "
'{"file": "<path>", "reason": "<why>"}'
)
print(f" {colored(error_message, Colors.RED)}")
errors += 1
continue
file_path = data.get("file")
entry_type = data.get("type", "file")
if not file_path:
# Seed / comment row — skip silently
if "file" in data or "path" in data or "reason" in data:
print(colored(f"{file_name}:{line_num}: file must be a non-empty string path", Colors.RED))
errors += 1
continue
if not isinstance(file_path, str):
# A truthy non-string (e.g. {"file": 1}) reached path joining and
# raised TypeError, so validation crashed on the row it exists to
# report.
print(
f" {colored(f'{file_name}:{line_num}: `file` must be a string path', Colors.RED)}"
)
errors += 1
continue
if not isinstance(data.get("reason"), str) or not data["reason"].strip():
print(colored(f"{file_name}:{line_num}: reason must be a non-empty string", Colors.RED))
errors += 1
continue
real_entries += 1
full_path = repo_root / file_path
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
if entry_type == "directory":
if not full_path.is_dir():
if full_path is None or not full_path.is_dir():
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
errors += 1
continue
if not full_path.is_file():
if full_path is None or not full_path.is_file():
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
errors += 1
continue
@@ -244,8 +383,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
print(f" {colored(warning_message, Colors.YELLOW)}")
if max_file_bytes:
size = full_path.stat().st_size
if size > max_file_bytes:
# Advisory hygiene warning, so it must never be what fails
# `validate`. `stat()` can still raise after the `is_file()` check
# above — a permission change, or the file removed in between.
try:
size: int | None = full_path.stat().st_size
except OSError:
size = None
if size is not None and size > max_file_bytes:
warning_message = (
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
@@ -253,6 +398,22 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
)
print(f" {colored(warning_message, Colors.YELLOW)}")
if errors == 0 and real_entries == 0 and not inline:
# Seed-only / empty manifest: sub-agents dispatched for this task
# would run with zero spec context (#573). Silent-green here is how
# more than half the tasks in the report ended up uncurated.
action = file_name.split(".", 1)[0]
print(
f" {colored(f'{file_name}: ✗ (0 curated entries — sub-agents would get zero spec context)', Colors.RED)}"
)
print(
f" Curate it: python3 .trellis/scripts/task.py add-context <task> {action} <path> \"<why>\""
)
print(
" Intentionally empty? Bypass at start: task.py start <task> --allow-empty-context"
)
return 1
if errors == 0:
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
else:
@@ -270,7 +431,7 @@ def cmd_list_context(args: argparse.Namespace) -> int:
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if not target_dir.is_dir():
if target_dir is None or not target_dir.is_dir():
print(colored("Error: task directory required", Colors.RED))
return 1
@@ -285,7 +446,7 @@ def cmd_list_context(args: argparse.Namespace) -> int:
print(colored(f"[{jsonl_name}]", Colors.CYAN))
count = 0
seed_only = True
curated = False
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
@@ -295,11 +456,14 @@ def cmd_list_context(args: argparse.Namespace) -> int:
except json.JSONDecodeError:
continue
if not isinstance(data, dict):
continue
file_path = data.get("file")
if not file_path:
# Seed / comment row — don't count as a real entry
# Placeholder / comment row — don't count as a real entry
continue
seed_only = False
curated = True
count += 1
entry_type = data.get("type", "file")
@@ -311,8 +475,8 @@ def cmd_list_context(args: argparse.Namespace) -> int:
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
print(f" {colored('→', Colors.YELLOW)} {reason}")
if seed_only:
print(f" {colored('(no curated entries yet — only seed row)', Colors.YELLOW)}")
if not curated:
print(f" {colored('(no curated entries yet)', Colors.YELLOW)}")
print()