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

这个提交包含在:
2026-07-26 18:10:49 +08:00
父节点 b3dadfa1bf
当前提交 f223f559ad
共修改 77 个文件,包含 5850 行新增和 1327 行删除
+284 -36
查看文件
@@ -9,6 +9,7 @@ Provides:
cmd_set_branch - Set git branch for task
cmd_set_base_branch - Set PR target branch
cmd_set_scope - Set scope for PR title
cmd_set_meta - Set/overwrite a task metadata key
cmd_add_subtask - Link child task to parent
cmd_remove_subtask - Unlink child task from parent
"""
@@ -23,13 +24,14 @@ from datetime import datetime
from pathlib import Path
from .config import (
get_codex_dispatch_mode,
get_packages,
get_session_auto_commit,
is_monorepo,
resolve_package,
validate_package,
)
from .git import run_git
from .git import branch_exists_locally, resolve_default_branch, run_git
from .io import read_json, write_json
from .log import Colors, colored
from .paths import (
@@ -50,6 +52,7 @@ from .safe_commit import (
from .task_utils import (
archive_task_complete,
find_task_by_name,
is_within_tasks_dir,
resolve_task_dir,
run_task_hooks,
)
@@ -113,13 +116,13 @@ def _repo_relative_path(path: Path, repo_root: Path) -> str:
# Config directories of platforms that consume implement.jsonl / check.jsonl.
# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
# platforms listed in workflow.md's "agent-capable" Skill Routing block
# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
# Windsurf are NOT in this list: they do not consume JSONL.
# platforms listed in workflow.md's "agent-capable" Skill Routing block.
# Codex is checked separately because explicit inline mode does not consume
# JSONL. Kilo / Antigravity / Devin are NOT in this list either: they load
# specs through skills instead of JSONL.
_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
".claude",
".cursor",
".codex",
".kiro",
".gemini",
".opencode",
@@ -128,7 +131,13 @@ _SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
".factory", # Factory Droid
".github/copilot",
".pi", # Pi Agent
".trae", # Trae IDE
".omp", # Oh My Pi
".zcode", # ZCode
".grok", # Grok Build
".kimi-code", # Kimi Code
)
_CODEX_CONFIG_DIR = ".codex"
_SEED_EXAMPLE = (
"Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
@@ -141,13 +150,16 @@ _SEED_EXAMPLE = (
def _has_subagent_platform(repo_root: Path) -> bool:
"""Return True if any sub-agent-capable platform is configured.
Detected by probing well-known config directories at the repo root. Used
only to decide whether ``task.py create`` should seed empty
``implement.jsonl`` / ``check.jsonl`` files.
Detected by probing well-known config directories at the repo root. Codex
counts by default through ``codex.dispatch_mode: auto`` (including the
legacy ``sub-agent`` alias); explicit inline mode loads context through
skills, not JSONL.
"""
for config_dir in _SUBAGENT_CONFIG_DIRS:
if (repo_root / config_dir).is_dir():
return True
if (repo_root / _CODEX_CONFIG_DIR).is_dir():
return get_codex_dispatch_mode(repo_root) == "auto"
return False
@@ -162,6 +174,52 @@ def _write_seed_jsonl(path: Path) -> None:
path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None:
"""Parse repeatable ``--meta key=value`` pairs into a dict.
Returns ``None`` (after printing an error naming the bad value) on the
first malformed pair: missing ``=`` or an empty key. Values are stored
as-is (strings, no nesting, no type coercion).
"""
meta: dict[str, str] = {}
for pair in pairs or []:
key, sep, value = pair.partition("=")
if not sep or not key:
print(
colored(f"Error: malformed --meta value '{pair}' (expected key=value)", Colors.RED),
file=sys.stderr,
)
return None
meta[key] = value
return meta
def _default_prd_content(title: str, description: str | None = None) -> str:
"""Return the default PRD skeleton created with every task."""
goal = (description or "").strip() or "TBD."
heading = title.strip() or "Untitled task"
return f"""# {heading}
## Goal
{goal}
## Requirements
- TBD
## Acceptance Criteria
- [ ] TBD
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
"""
# =============================================================================
# Command: create
# =============================================================================
@@ -174,6 +232,11 @@ def cmd_create(args: argparse.Namespace) -> int:
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
return 1
# Validate --meta (CLI source: fail-fast, before any directory is created)
meta = _parse_meta_pairs(getattr(args, "meta", None))
if meta is None:
return 1
# Validate --package (CLI source: fail-fast)
package: str | None = getattr(args, "package", None)
if not is_monorepo(repo_root):
@@ -213,6 +276,34 @@ def cmd_create(args: argparse.Namespace) -> int:
# Create task directory with MM-DD-slug format
tasks_dir = get_tasks_dir(repo_root)
date_prefix = generate_task_date_prefix()
# Guard against date-prefixed --slug (e.g. a full task dir name pasted in),
# which would otherwise produce MM-DD-MM-DD-slug (issue #377). Only an
# explicit --slug is guarded; title-derived slugs are left untouched.
if args.slug:
m = re.match(r"^(\d{2})-(\d{2})-(.+)$", slug)
if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31:
slug_prefix = f"{m.group(1)}-{m.group(2)}"
if slug_prefix == date_prefix:
slug = m.group(3)
print(
colored(
f'warning: --slug should not include the MM-DD prefix; normalized to "{slug}"',
Colors.YELLOW,
),
file=sys.stderr,
)
else:
print(
colored(
f"Error: --slug starts with a date prefix ({slug_prefix}-), but task.py create always uses today's date ({date_prefix}).",
Colors.RED,
),
file=sys.stderr,
)
print(f"Pass only the slug body, e.g. --slug {m.group(3)}", file=sys.stderr)
return 1
dir_name = f"{date_prefix}-{slug}"
task_dir = tasks_dir / dir_name
task_json_path = task_dir / FILE_TASK_JSON
@@ -231,15 +322,48 @@ def cmd_create(args: argparse.Namespace) -> int:
today = datetime.now().strftime("%Y-%m-%d")
# Record current branch as base_branch (PR target)
# Record the PR target branch. Prefer the repo's actual default branch
# (origin/HEAD) so creating a task from a feature branch doesn't
# mis-stamp that feature branch as the PR target (#399 item 1). Falls
# back to the checked-out branch when the default can't be resolved
# (no remote configured, offline, etc.) — the pre-existing behavior.
# --base-branch lets the caller override both when neither is correct.
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
current_branch = branch_out.strip() or "main"
explicit_base_branch: str | None = getattr(args, "base_branch", None)
if explicit_base_branch:
base_branch = explicit_base_branch
else:
resolved_base_branch = resolve_default_branch(repo_root)
if resolved_base_branch:
base_branch = resolved_base_branch
else:
base_branch = current_branch
print(
colored(
f"warning: could not resolve the repository's default branch "
f"(no remote configured, offline, etc.); stamping base_branch as "
f"the checked-out branch '{base_branch}'. Pass --base-branch to override.",
Colors.YELLOW,
),
file=sys.stderr,
)
description = (args.description or "").strip()
if not description.strip():
print(
colored(
"warning: task description is empty; pass --description to improve search and later audits.",
Colors.YELLOW,
),
file=sys.stderr,
)
task_data = {
"id": slug,
"name": slug,
"title": args.title,
"description": args.description or "",
"description": description,
"status": "planning",
"dev_type": None,
"scope": None,
@@ -250,7 +374,7 @@ def cmd_create(args: argparse.Namespace) -> int:
"createdAt": today,
"completedAt": None,
"branch": None,
"base_branch": current_branch,
"base_branch": base_branch,
"worktree_path": None,
"commit": None,
"pr_url": None,
@@ -259,14 +383,21 @@ def cmd_create(args: argparse.Namespace) -> int:
"parent": None,
"relatedFiles": [],
"notes": "",
"meta": {},
"meta": meta,
}
write_json(task_json_path, task_data)
prd_path = task_dir / "prd.md"
if not prd_path.exists():
prd_path.write_text(
_default_prd_content(args.title, description),
encoding="utf-8",
)
# Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
# Agent curates real entries in Phase 1.3 (see .trellis/workflow.md).
# Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they
# Agent curates real entries during planning when the task needs them.
# Agent-less platforms (Kilo / Antigravity / Devin) skip this — they
# load specs via the trellis-before-dev skill instead of JSONL.
seeded_jsonl = False
if _has_subagent_platform(repo_root):
@@ -303,30 +434,70 @@ def cmd_create(args: argparse.Namespace) -> int:
# outside an AI session) — the task is still created, the user can run
# task.py start later. Pointer is session-scoped so this never affects
# other AI sessions.
try:
from .active_task import resolve_context_key, set_active_task
if resolve_context_key():
if getattr(args, "no_start", False):
print(
colored(
"Skipped session activation (--no-start); run task.py start when ready.",
Colors.YELLOW,
),
file=sys.stderr,
)
else:
try:
from .active_task import resolve_context_key, set_active_task
except Exception as exc:
print(
colored(f"Warning: session activation unavailable (import failed: {exc})", Colors.YELLOW),
file=sys.stderr,
)
else:
try:
rel_dir = task_dir.relative_to(repo_root).as_posix()
except ValueError:
rel_dir = str(task_dir)
set_active_task(rel_dir, repo_root)
except Exception:
pass
context_key = resolve_context_key()
except Exception as exc:
print(
colored(f"Warning: session activation failed (context resolution: {exc})", Colors.YELLOW),
file=sys.stderr,
)
else:
# No session identity is the normal CLI-outside-an-AI-session
# case (see comment above) — stay silent, not a failure.
if context_key:
try:
rel_dir = task_dir.relative_to(repo_root).as_posix()
except ValueError:
rel_dir = str(task_dir)
try:
active = set_active_task(rel_dir, repo_root)
except Exception as exc:
print(
colored(f"Warning: session activation failed (pointer persistence: {exc})", Colors.YELLOW),
file=sys.stderr,
)
else:
if active:
print(
colored(f"Activated task for this session: {active.task_path}", Colors.GREEN),
file=sys.stderr,
)
print(f"Source: {active.source}", file=sys.stderr)
else:
print(
colored("Warning: session activation failed (no pointer returned)", Colors.YELLOW),
file=sys.stderr,
)
print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
print("", file=sys.stderr)
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
print(" 1. Create prd.md with requirements", file=sys.stderr)
print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr)
if seeded_jsonl:
print(
" 2. Curate implement.jsonl / check.jsonl (spec + research files only — "
"see .trellis/workflow.md Phase 1.3)",
" - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context",
file=sys.stderr,
)
print(" 3. Run: python3 task.py start <dir>", file=sys.stderr)
else:
print(" 2. Run: python3 task.py start <dir>", file=sys.stderr)
print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr)
print("", file=sys.stderr)
# Output relative path for script chaining
@@ -363,6 +534,17 @@ def cmd_archive(args: argparse.Namespace) -> int:
print(f" - {t.dir_name}/", file=sys.stderr)
return 1
# Refuse to archive anything that isn't a real task directly under
# .trellis/tasks/. A mistyped name (e.g. "src") resolves to repo_root/src,
# which is a dir but not a task — without this guard archive would move the
# user's source directory out of the repo.
if not is_within_tasks_dir(task_dir, repo_root):
print(colored(
f"Error: refusing to archive '{task_name}': "
f"{task_dir} is not a task under {tasks_dir}",
Colors.RED), file=sys.stderr)
return 1
dir_name = task_dir.name
task_json_path = task_dir / FILE_TASK_JSON
@@ -374,6 +556,19 @@ def cmd_archive(args: argparse.Namespace) -> int:
if task_json_path.is_file():
data = read_json(task_json_path)
if data:
# Warn (don't block) when the recorded branch is stale — it was
# likely already merged and deleted (#399 item 2).
stored_branch = data.get("branch")
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,
),
file=sys.stderr,
)
data["status"] = "completed"
data["completedAt"] = today
write_json(task_json_path, data)
@@ -410,7 +605,16 @@ def cmd_archive(args: argparse.Namespace) -> int:
# Auto-commit unless --no-commit
if not getattr(args, "no_commit", False):
_auto_commit_archive(dir_name, repo_root, modified_children)
if not _auto_commit_archive(dir_name, repo_root, modified_children):
print(
colored(
"Archive moved on disk, but git auto-commit did not complete. "
"Resolve `git status` before continuing.",
Colors.RED,
),
file=sys.stderr,
)
return 1
# Return the archive path
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
@@ -427,7 +631,7 @@ def _auto_commit_archive(
task_name: str,
repo_root: Path,
modified_children: list[str] | None = None,
) -> None:
) -> bool:
"""Stage Trellis-owned task paths and commit after archive.
Scoped narrowly to the archived task's source + destination paths
@@ -449,14 +653,21 @@ def _auto_commit_archive(
"[OK] session_auto_commit: false — skipping git stage/commit.",
file=sys.stderr,
)
return
return True
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
rc, tracked_out, _ = run_git(
["ls-files", "--", source_rel],
cwd=repo_root,
)
source_was_tracked = rc == 0 and bool(tracked_out.strip())
paths = safe_archive_paths_to_add(
repo_root, task_name=task_name, modified_children=modified_children
)
if not paths:
print("[OK] No task changes to commit.", file=sys.stderr)
return
return True
success, _, err = safe_git_add(paths, repo_root)
if not success:
@@ -467,7 +678,7 @@ def _auto_commit_archive(
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
file=sys.stderr,
)
return
return not source_was_tracked
# Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
# `git add` (no -A) which only stages additions/modifications. The
@@ -478,7 +689,6 @@ def _auto_commit_archive(
#
# `--ignore-unmatch` makes this a no-op when the task was never tracked
# (e.g. archiving a task that lived only in working tree).
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
run_git(
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
cwd=repo_root,
@@ -490,14 +700,16 @@ def _auto_commit_archive(
)
if rc == 0:
print("[OK] No task changes to commit.", file=sys.stderr)
return
return True
commit_msg = f"chore(task): archive {task_name}"
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
if rc == 0:
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
return True
else:
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
return not source_was_tracked
# =============================================================================
@@ -695,3 +907,39 @@ def cmd_set_scope(args: argparse.Namespace) -> int:
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
return 0
# =============================================================================
# Command: set-meta
# =============================================================================
def cmd_set_meta(args: argparse.Namespace) -> int:
"""Set/overwrite one metadata key on an existing task."""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
key = args.key
value = args.value
if not key:
print(colored("Error: Missing arguments", Colors.RED))
print("Usage: python3 task.py set-meta <task-dir> <key> <value>")
return 1
task_json = target_dir / FILE_TASK_JSON
if not task_json.is_file():
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
return 1
data = read_json(task_json)
if not data:
return 1
meta = data.get("meta")
if not isinstance(meta, dict):
meta = {}
meta[key] = value
data["meta"] = meta
write_json(task_json, data)
print(colored(f"✓ Meta set: {key} = {value}", Colors.GREEN))
return 0