e567e5f717
- Updated bundled skills documentation to clarify the structure and usage across all platforms, ensuring consistency in skill root paths. - Introduced a new `inject-spec-context.py` hook for path-scoped spec context injection, improving the relevance of injected specs during file interactions. - Enhanced existing hooks to support workflow resolution, allowing for dynamic selection of workflows based on task context. - Added a command to manage workflow selections for active tasks, enabling better task management and workflow adherence. - Updated configuration options for spec injection, including character limits and refresh windows, to optimize performance and usability.
373 lines
12 KiB
Python
Executable File
373 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Task JSONL context management.
|
|
|
|
Provides:
|
|
cmd_add_context - Add entry to JSONL context file
|
|
cmd_validate - Validate JSONL context files
|
|
cmd_list_context - List JSONL context entries
|
|
|
|
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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from .config import get_context_injection_limits
|
|
from .git import branch_exists_locally
|
|
from .io import read_json
|
|
from .log import Colors, colored
|
|
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
|
|
# one of these extensions outside .trellis/spec/, docs/docs-site, or the
|
|
# task's own directory get a hygiene warning in `task.py validate` — the
|
|
# reader is a sub-agent, not a human, so code paths belong in the diff the
|
|
# agent reads itself, not in implement.jsonl / check.jsonl.
|
|
_CODE_FILE_EXTENSIONS = {
|
|
".ts",
|
|
".tsx",
|
|
".js",
|
|
".jsx",
|
|
".mjs",
|
|
".cjs",
|
|
".py",
|
|
".go",
|
|
".rs",
|
|
".java",
|
|
".rb",
|
|
".c",
|
|
".cc",
|
|
".cpp",
|
|
".h",
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Command: add-context
|
|
# =============================================================================
|
|
|
|
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)
|
|
|
|
jsonl_name = args.file
|
|
path = args.path
|
|
reason = args.reason or "Added manually"
|
|
|
|
if not target_dir.is_dir():
|
|
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
|
return 1
|
|
|
|
# Support shorthand
|
|
if not jsonl_name.endswith(".jsonl"):
|
|
jsonl_name = f"{jsonl_name}.jsonl"
|
|
|
|
jsonl_file = target_dir / jsonl_name
|
|
full_path = repo_root / path
|
|
|
|
entry_type = "file"
|
|
if full_path.is_dir():
|
|
entry_type = "directory"
|
|
if not path.endswith("/"):
|
|
path = f"{path}/"
|
|
elif not full_path.is_file():
|
|
print(colored(f"Error: Path not found: {path}", Colors.RED))
|
|
return 1
|
|
|
|
# Check if already exists
|
|
if jsonl_file.is_file():
|
|
content = jsonl_file.read_text(encoding="utf-8")
|
|
if f'"{path}"' in content:
|
|
print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW))
|
|
return 0
|
|
|
|
# Add entry
|
|
entry: dict
|
|
if entry_type == "directory":
|
|
entry = {"file": path, "type": "directory", "reason": reason}
|
|
else:
|
|
entry = {"file": path, "reason": reason}
|
|
|
|
with jsonl_file.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
|
|
print(colored(f"Added {entry_type}: {path}", Colors.GREEN))
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Command: validate
|
|
# =============================================================================
|
|
|
|
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():
|
|
print(colored("Error: task directory required", Colors.RED))
|
|
return 1
|
|
|
|
print(colored("=== Validating Context Files ===", Colors.BLUE))
|
|
print(f"Target dir: {target_dir}")
|
|
print()
|
|
|
|
# Warn (don't fail validation) when the recorded branch is stale — it
|
|
# was likely already merged and deleted (#399 item 2).
|
|
task_json_path = target_dir / FILE_TASK_JSON
|
|
if task_json_path.is_file():
|
|
task_data = read_json(task_json_path)
|
|
stored_branch = task_data.get("branch") if task_data else None
|
|
if stored_branch and not branch_exists_locally(stored_branch, repo_root):
|
|
print(
|
|
colored(
|
|
f"Warning: recorded branch '{stored_branch}' no longer exists locally "
|
|
"(likely merged and deleted).",
|
|
Colors.YELLOW,
|
|
)
|
|
)
|
|
print()
|
|
|
|
total_errors = 0
|
|
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
|
jsonl_file = target_dir / jsonl_name
|
|
errors = _validate_jsonl(jsonl_file, repo_root, target_dir)
|
|
total_errors += errors
|
|
|
|
print()
|
|
if total_errors == 0:
|
|
print(colored("✓ All validations passed", Colors.GREEN))
|
|
return 0
|
|
else:
|
|
print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED))
|
|
return 1
|
|
|
|
|
|
def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
|
|
"""Whether a jsonl entry path is exempt from the code-file hygiene warning.
|
|
|
|
Exempt: spec docs (``.trellis/spec/``), documentation (``docs``,
|
|
``docs-site``), and the task's own directory (execution plans, generated
|
|
artifacts, etc. legitimately live there).
|
|
"""
|
|
posix_path = file_path.replace("\\", "/").lstrip("/")
|
|
exempt_prefixes = (".trellis/spec/", "docs/", "docs-site/")
|
|
if posix_path.startswith(exempt_prefixes):
|
|
return True
|
|
if task_rel and (posix_path == task_rel or posix_path.startswith(f"{task_rel}/")):
|
|
return True
|
|
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
|
|
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.
|
|
|
|
Beyond hard errors (missing file/dir, invalid JSON), this also prints
|
|
non-blocking hygiene warnings (never counted in ``errors``, never change
|
|
the exit code): entries that look like code files rather than
|
|
spec/research docs, and entries whose file size exceeds the configured
|
|
sub-agent context injection cap (``context_injection.max_file_bytes``).
|
|
"""
|
|
file_name = jsonl_file.name
|
|
errors = 0
|
|
|
|
if not jsonl_file.is_file():
|
|
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
|
return 0
|
|
|
|
task_rel = ""
|
|
if task_dir is not None:
|
|
try:
|
|
task_rel = task_dir.resolve().relative_to(repo_root.resolve()).as_posix()
|
|
except ValueError:
|
|
task_rel = ""
|
|
|
|
max_file_bytes = get_context_injection_limits(repo_root).get("max_file_bytes", 0)
|
|
|
|
line_num = 0
|
|
real_entries = 0
|
|
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
|
line_num += 1
|
|
if not line.strip():
|
|
continue
|
|
|
|
try:
|
|
data = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', 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
|
|
continue
|
|
|
|
real_entries += 1
|
|
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
|
|
if entry_type == "directory":
|
|
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 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
|
|
|
|
extension = Path(file_path).suffix.lower()
|
|
if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning(
|
|
file_path, task_rel
|
|
):
|
|
warning_message = (
|
|
f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — "
|
|
"implement/check.jsonl should reference spec/research docs; "
|
|
"agents read code themselves"
|
|
)
|
|
print(f" {colored(warning_message, Colors.YELLOW)}")
|
|
|
|
if max_file_bytes:
|
|
size = full_path.stat().st_size
|
|
if 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}); "
|
|
"injection will truncate it"
|
|
)
|
|
print(f" {colored(warning_message, Colors.YELLOW)}")
|
|
|
|
if errors == 0:
|
|
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
|
else:
|
|
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}")
|
|
|
|
return errors
|
|
|
|
|
|
# =============================================================================
|
|
# Command: list-context
|
|
# =============================================================================
|
|
|
|
def cmd_list_context(args: argparse.Namespace) -> int:
|
|
"""List JSONL context entries."""
|
|
repo_root = get_repo_root()
|
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
|
|
if not target_dir.is_dir():
|
|
print(colored("Error: task directory required", Colors.RED))
|
|
return 1
|
|
|
|
print(colored("=== Context Files ===", Colors.BLUE))
|
|
print()
|
|
|
|
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
|
jsonl_file = target_dir / jsonl_name
|
|
if not jsonl_file.is_file():
|
|
continue
|
|
|
|
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
|
|
|
count = 0
|
|
seed_only = True
|
|
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
|
if not line.strip():
|
|
continue
|
|
|
|
try:
|
|
data = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
file_path = data.get("file")
|
|
if not file_path:
|
|
# Seed / comment row — don't count as a real entry
|
|
continue
|
|
seed_only = False
|
|
|
|
count += 1
|
|
entry_type = data.get("type", "file")
|
|
reason = data.get("reason", "-")
|
|
|
|
if entry_type == "directory":
|
|
print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}")
|
|
else:
|
|
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)}")
|
|
|
|
print()
|
|
|
|
return 0
|