forked from mengyxu/noob-components
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
411 lines
14 KiB
411 lines
14 KiB
|
3 months ago
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Task JSONL context management.
|
||
|
|
|
||
|
|
Provides:
|
||
|
|
cmd_init_context - Initialize JSONL context files for a task
|
||
|
|
cmd_add_context - Add entry to JSONL context file
|
||
|
|
cmd_validate - Validate JSONL context files
|
||
|
|
cmd_list_context - List JSONL context entries
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from .cli_adapter import get_cli_adapter_auto
|
||
|
|
from .config import (
|
||
|
|
get_packages,
|
||
|
|
is_monorepo,
|
||
|
|
resolve_package,
|
||
|
|
validate_package,
|
||
|
|
)
|
||
|
|
from .io import read_json, write_json
|
||
|
|
from .log import Colors, colored
|
||
|
|
from .paths import (
|
||
|
|
DIR_SPEC,
|
||
|
|
DIR_WORKFLOW,
|
||
|
|
FILE_TASK_JSON,
|
||
|
|
get_repo_root,
|
||
|
|
)
|
||
|
|
from .task_utils import resolve_task_dir
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# JSONL Default Content Generators
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def get_implement_base() -> list[dict]:
|
||
|
|
"""Get base implement context entries."""
|
||
|
|
return [
|
||
|
|
{"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def get_implement_backend(package: str | None = None) -> list[dict]:
|
||
|
|
"""Get backend implement context entries."""
|
||
|
|
spec_base = f"{DIR_SPEC}/{package}" if package else DIR_SPEC
|
||
|
|
return [
|
||
|
|
{"file": f"{DIR_WORKFLOW}/{spec_base}/backend/index.md", "reason": "Backend development guide"},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def get_implement_frontend(package: str | None = None) -> list[dict]:
|
||
|
|
"""Get frontend implement context entries."""
|
||
|
|
spec_base = f"{DIR_SPEC}/{package}" if package else DIR_SPEC
|
||
|
|
return [
|
||
|
|
{"file": f"{DIR_WORKFLOW}/{spec_base}/frontend/index.md", "reason": "Frontend development guide"},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def get_check_context(repo_root: Path) -> list[dict]:
|
||
|
|
"""Get check context entries."""
|
||
|
|
adapter = get_cli_adapter_auto(repo_root)
|
||
|
|
|
||
|
|
entries = [
|
||
|
|
{"file": adapter.get_trellis_command_path("finish-work"), "reason": "Finish work checklist"},
|
||
|
|
{"file": adapter.get_trellis_command_path("check"), "reason": "Code quality check spec"},
|
||
|
|
]
|
||
|
|
|
||
|
|
return entries
|
||
|
|
|
||
|
|
|
||
|
|
def get_debug_context(repo_root: Path) -> list[dict]:
|
||
|
|
"""Get debug context entries."""
|
||
|
|
adapter = get_cli_adapter_auto(repo_root)
|
||
|
|
|
||
|
|
entries: list[dict] = [
|
||
|
|
{"file": adapter.get_trellis_command_path("check"), "reason": "Code quality check spec"},
|
||
|
|
]
|
||
|
|
|
||
|
|
return entries
|
||
|
|
|
||
|
|
|
||
|
|
def _write_jsonl(path: Path, entries: list[dict]) -> None:
|
||
|
|
"""Write entries to JSONL file."""
|
||
|
|
lines = [json.dumps(entry, ensure_ascii=False) for entry in entries]
|
||
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Command: init-context
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_init_context(args: argparse.Namespace) -> int:
|
||
|
|
"""Initialize JSONL context files for a task."""
|
||
|
|
repo_root = get_repo_root()
|
||
|
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
||
|
|
dev_type = args.type
|
||
|
|
|
||
|
|
if not dev_type:
|
||
|
|
print(colored("Error: Missing arguments", Colors.RED))
|
||
|
|
print("Usage: python3 task.py init-context <task-dir> <dev_type>")
|
||
|
|
print(" dev_type: backend | frontend | fullstack | test | docs")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
if not target_dir.is_dir():
|
||
|
|
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
|
||
|
|
return 1
|
||
|
|
|
||
|
|
# Resolve package: --package CLI → task.json.package → default_package
|
||
|
|
cli_package: str | None = getattr(args, "package", None)
|
||
|
|
package: str | None = None
|
||
|
|
if not is_monorepo(repo_root):
|
||
|
|
# Single-repo: ignore --package, no package prefix
|
||
|
|
if cli_package:
|
||
|
|
print(colored("Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr)
|
||
|
|
elif cli_package:
|
||
|
|
if not validate_package(cli_package, repo_root):
|
||
|
|
packages = get_packages(repo_root)
|
||
|
|
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
||
|
|
print(colored(f"Error: unknown package '{cli_package}'. Available: {available}", Colors.RED), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
package = cli_package
|
||
|
|
else:
|
||
|
|
# Read task.json.package as inferred source
|
||
|
|
task_json_path = target_dir / FILE_TASK_JSON
|
||
|
|
task_pkg_value = None
|
||
|
|
if task_json_path.is_file():
|
||
|
|
task_data = read_json(task_json_path)
|
||
|
|
if isinstance(task_data, dict):
|
||
|
|
task_pkg_value = task_data.get("package")
|
||
|
|
# Only pass string values to resolve_package (guard against malformed JSON)
|
||
|
|
task_package = task_pkg_value if isinstance(task_pkg_value, str) else None
|
||
|
|
package = resolve_package(task_package=task_package, repo_root=repo_root)
|
||
|
|
|
||
|
|
# Monorepo fallback prohibition
|
||
|
|
if package is None:
|
||
|
|
packages = get_packages(repo_root)
|
||
|
|
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
||
|
|
print(colored(
|
||
|
|
f"Error: monorepo project requires --package (or set default_package in config.yaml). Available: {available}",
|
||
|
|
Colors.RED,
|
||
|
|
), file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
|
||
|
|
print(colored("=== Initializing Agent Context Files ===", Colors.BLUE))
|
||
|
|
print(f"Target dir: {target_dir}")
|
||
|
|
print(f"Dev type: {dev_type}")
|
||
|
|
if package:
|
||
|
|
print(f"Package: {package}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
# implement.jsonl
|
||
|
|
print(colored("Creating implement.jsonl...", Colors.CYAN))
|
||
|
|
implement_entries = get_implement_base()
|
||
|
|
if dev_type in ("backend", "test"):
|
||
|
|
implement_entries.extend(get_implement_backend(package))
|
||
|
|
elif dev_type == "frontend":
|
||
|
|
implement_entries.extend(get_implement_frontend(package))
|
||
|
|
elif dev_type == "fullstack":
|
||
|
|
implement_entries.extend(get_implement_backend(package))
|
||
|
|
implement_entries.extend(get_implement_frontend(package))
|
||
|
|
|
||
|
|
implement_file = target_dir / "implement.jsonl"
|
||
|
|
_write_jsonl(implement_file, implement_entries)
|
||
|
|
print(f" {colored('✓', Colors.GREEN)} {len(implement_entries)} entries")
|
||
|
|
|
||
|
|
# check.jsonl
|
||
|
|
print(colored("Creating check.jsonl...", Colors.CYAN))
|
||
|
|
check_entries = get_check_context(repo_root)
|
||
|
|
check_file = target_dir / "check.jsonl"
|
||
|
|
_write_jsonl(check_file, check_entries)
|
||
|
|
print(f" {colored('✓', Colors.GREEN)} {len(check_entries)} entries")
|
||
|
|
|
||
|
|
# debug.jsonl
|
||
|
|
print(colored("Creating debug.jsonl...", Colors.CYAN))
|
||
|
|
debug_entries = get_debug_context(repo_root)
|
||
|
|
debug_file = target_dir / "debug.jsonl"
|
||
|
|
_write_jsonl(debug_file, debug_entries)
|
||
|
|
print(f" {colored('✓', Colors.GREEN)} {len(debug_entries)} entries")
|
||
|
|
|
||
|
|
# Update task.json dev_type and package
|
||
|
|
task_json_path = target_dir / FILE_TASK_JSON
|
||
|
|
if task_json_path.is_file():
|
||
|
|
task_data = read_json(task_json_path)
|
||
|
|
if isinstance(task_data, dict):
|
||
|
|
task_data["dev_type"] = dev_type
|
||
|
|
task_data["package"] = package # Always sync to match resolved value
|
||
|
|
write_json(task_json_path, task_data)
|
||
|
|
|
||
|
|
print()
|
||
|
|
print(colored("✓ All context files created", Colors.GREEN))
|
||
|
|
print()
|
||
|
|
|
||
|
|
# Show what was auto-injected
|
||
|
|
all_injected = [e["file"] for e in implement_entries]
|
||
|
|
print(colored("Auto-injected (defaults only):", Colors.YELLOW))
|
||
|
|
for f in all_injected:
|
||
|
|
print(f" - {f}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
# Scan spec directory for available spec files the AI should consider
|
||
|
|
spec_base = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||
|
|
if package:
|
||
|
|
spec_base = spec_base / package
|
||
|
|
available_specs: list[str] = []
|
||
|
|
if spec_base.is_dir():
|
||
|
|
for md_file in sorted(spec_base.rglob("*.md")):
|
||
|
|
rel = str(md_file.relative_to(repo_root))
|
||
|
|
if rel not in all_injected:
|
||
|
|
available_specs.append(rel)
|
||
|
|
|
||
|
|
if available_specs:
|
||
|
|
print(colored("Available spec files (not yet injected):", Colors.BLUE))
|
||
|
|
for spec in available_specs:
|
||
|
|
print(f" - {spec}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
print(colored("Next steps:", Colors.BLUE))
|
||
|
|
print(" 1. Review the spec files above and add relevant ones for your task:")
|
||
|
|
print(f" python3 task.py add-context <dir> implement <spec-path> \"<reason>\"")
|
||
|
|
print(" 2. Set as current: python3 task.py start <dir>")
|
||
|
|
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 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()
|
||
|
|
|
||
|
|
total_errors = 0
|
||
|
|
for jsonl_name in ["implement.jsonl", "check.jsonl", "debug.jsonl"]:
|
||
|
|
jsonl_file = target_dir / jsonl_name
|
||
|
|
errors = _validate_jsonl(jsonl_file, repo_root)
|
||
|
|
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 _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int:
|
||
|
|
"""Validate a single JSONL file."""
|
||
|
|
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
|
||
|
|
|
||
|
|
line_num = 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:
|
||
|
|
print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}")
|
||
|
|
errors += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
full_path = repo_root / file_path
|
||
|
|
if entry_type == "directory":
|
||
|
|
if not full_path.is_dir():
|
||
|
|
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||
|
|
errors += 1
|
||
|
|
else:
|
||
|
|
if not full_path.is_file():
|
||
|
|
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||
|
|
errors += 1
|
||
|
|
|
||
|
|
if errors == 0:
|
||
|
|
print(f" {colored(f'{file_name}: ✓ ({line_num} 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", "debug.jsonl"]:
|
||
|
|
jsonl_file = target_dir / jsonl_name
|
||
|
|
if not jsonl_file.is_file():
|
||
|
|
continue
|
||
|
|
|
||
|
|
print(colored(f"[{jsonl_name}]", Colors.CYAN))
|
||
|
|
|
||
|
|
count = 0
|
||
|
|
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
|
||
|
|
|
||
|
|
count += 1
|
||
|
|
file_path = data.get("file", "?")
|
||
|
|
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}")
|
||
|
|
|
||
|
|
print()
|
||
|
|
|
||
|
|
return 0
|