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