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

这个提交包含在:
2026-09-06 11:14:41 +08:00
父节点 ab82fe30c0
当前提交 c3cef5cfc4
共修改 48 个文件,包含 4588 行新增和 1053 行删除
+232 -32
查看文件
@@ -4,18 +4,19 @@
Task Management Script.
Usage:
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start]
python3 task.py create "<title>" --description "<desc>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] [--force]
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
python3 task.py validate <dir> # Validate jsonl files
python3 task.py list-context <dir> # List jsonl entries
python3 task.py start <dir> # Set active task
python3 task.py start <dir> # Set active task, record current branch
python3 task.py current [--source] [--json] # Show active task
python3 task.py finish # Clear active task
python3 task.py set-branch <dir> <branch> # Set git branch
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
python3 task.py set-scope <dir> <scope> # Set scope for PR title
python3 task.py set-meta <dir> <key> <value> # Set a task metadata key
python3 task.py archive <task-dir> # Archive completed task
python3 task.py rename <dir> <new-slug> [--dry-run] # Rename task + references
python3 task.py archive <task-dir> [--skip-branch-validation] # Archive completed task
python3 task.py list # List active tasks
python3 task.py list-archive [month] # List archived tasks
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
@@ -27,9 +28,12 @@ from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from common.log import Colors, colored
from common.config import get_codex_dispatch_mode
from common.paths import (
DEVELOPER_HINT,
DIR_WORKFLOW,
DIR_TASKS,
FILE_TASK_JSON,
@@ -44,13 +48,19 @@ from common.active_task import (
resolve_context_key,
set_active_task,
)
from common.io import read_json, write_json
from common.git import current_branch_name
from common.io import (
describe_json_read_failure,
read_json_checked,
write_json,
)
from common.task_utils import resolve_task_dir, run_task_hooks
from common.tasks import iter_active_tasks, children_progress
# Import command handlers from split modules (also re-exports for plan.py compatibility)
from common.task_store import (
cmd_create,
cmd_rename,
cmd_archive,
cmd_set_branch,
cmd_set_base_branch,
@@ -63,6 +73,7 @@ from common.task_context import (
cmd_add_context,
cmd_validate,
cmd_list_context,
curated_entry_count,
)
@@ -70,6 +81,94 @@ from common.task_context import (
# Command: start / finish
# =============================================================================
def _record_start_state(
task_json_path: Path,
repo_root: Path,
label: str = "",
) -> None:
"""Move a freshly started task to in_progress and record its branch.
Both updates share one read/write: the status flip from planning, and the
checked-out branch when `branch` is still empty. Recording at start is what
keeps `branch` trustworthy at archive time — a task whose branch is only
ever set by hand tends to reach archive with `branch: null`.
Tolerant on purpose — a broken task.json does not fail `start`, because the
session pointer is the point of the command. But the read overwrites the
file it just read, so no failure may be silent: without a message the
absent status line looks like the task simply was not in planning.
"""
data, reason = read_json_checked(task_json_path)
if data is None:
problem, hint = describe_json_read_failure(task_json_path, reason)
print(
colored(f"Warning: {problem}; task.json not updated.", Colors.YELLOW),
file=sys.stderr,
)
print(hint, file=sys.stderr)
return
applied: list[str] = []
if data.get("status") == "planning":
data["status"] = "in_progress"
applied.append(f"✓ Status: planning → in_progress{label}")
# Only fill an empty field: an explicit `set-branch` must survive a later
# `start` (re-starting a task after a checkout is a normal thing to do).
base_branch_conflict: str | None = None
if not data.get("branch"):
branch = current_branch_name(repo_root)
if branch:
data["branch"] = branch
applied.append(f"✓ Branch recorded: {branch}{label}")
if branch == data.get("base_branch"):
base_branch_conflict = branch
else:
print(
colored(
"Note: no checked-out branch (detached HEAD, or not a git "
"repository); task branch not recorded.",
Colors.YELLOW,
),
file=sys.stderr,
)
if not applied:
return
if not write_json(task_json_path, data):
print(
colored(
f"Warning: Failed to write {task_json_path}; "
"status and branch are unchanged.",
Colors.YELLOW,
),
file=sys.stderr,
)
return
for line in applied:
print(colored(line, Colors.GREEN))
if base_branch_conflict:
# Recorded anyway — the value is true, it just cannot describe a PR.
# Archive refuses this shape, so say so now rather than at the gate.
print(
colored(
f"Warning: '{base_branch_conflict}' is also this task's base_branch; "
"a PR cannot target its own branch, and archive will refuse it.",
Colors.YELLOW,
),
file=sys.stderr,
)
print(
f"Once you branch off, run: python3 {DIR_WORKFLOW}/scripts/task.py "
"set-branch <task> <feature-branch>",
file=sys.stderr,
)
def cmd_start(args: argparse.Namespace) -> int:
"""Set active task."""
repo_root = get_repo_root()
@@ -82,16 +181,55 @@ def cmd_start(args: argparse.Namespace) -> int:
# Resolve task directory (supports task name, relative path, or absolute path)
full_path = resolve_task_dir(task_input, repo_root)
if full_path is None:
# resolve_task_dir already named the exact reason on stderr. A second,
# generic line on stdout would split one diagnosis across two streams
# and bury the specific message.
return 1
if not full_path.is_dir():
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
return 1
# Convert to relative path for storage
# Context-manifest gate (#573): a seeded-but-uncurated implement/check
# manifest means every sub-agent dispatched for this task runs with zero
# spec context, and nothing downstream surfaces that to the main session.
# An absent manifest is not gated — create seeds the files only on
# sub-agent-capable platforms, so absence means no sub-agent reads them.
if get_codex_dispatch_mode(repo_root) != "inline" and not getattr(args, "allow_empty_context", False):
empty_manifests = [
name
for name in ("implement.jsonl", "check.jsonl")
if curated_entry_count(full_path / name) == 0
]
if empty_manifests:
print(colored(
f"Error: {' and '.join(empty_manifests)} "
f"{'has' if len(empty_manifests) == 1 else 'have'} no curated entries",
Colors.RED,
))
print("Sub-agents (implement/check) would run with zero spec context.")
print(f" Curate: python3 .trellis/scripts/task.py add-context {task_input} implement <path> \"<why>\"")
print(f" Verify: python3 .trellis/scripts/task.py validate {task_input}")
print(" Intentionally empty? Re-run start with --allow-empty-context")
return 1
# Convert to relative path for storage. repo_root is resolved because
# full_path already is (resolve_task_dir only returns paths inside the
# resolved root), so an unresolved repo_root would mismatch under a
# symlink (e.g. /tmp on macOS) and reject a perfectly normal task.
try:
task_dir = full_path.relative_to(repo_root).as_posix()
task_dir = full_path.relative_to(repo_root.resolve()).as_posix()
except ValueError:
task_dir = str(full_path)
# resolve_task_dir already refused everything outside the repo, so
# this is unreachable in practice. Refuse rather than fall back to
# str(full_path) — that fallback (a lexical relative_to() paired with
# an absolute-path fallback) is exactly the pattern that let a `..`
# ref escape into storage before this fix.
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
return 1
task_json_path = full_path / FILE_TASK_JSON
@@ -113,11 +251,7 @@ def cmd_start(args: argparse.Namespace) -> int:
# Still flip task.json status: planning → in_progress so downstream phases proceed.
if task_json_path.is_file():
data = read_json(task_json_path)
if data and data.get("status") == "planning":
data["status"] = "in_progress"
if write_json(task_json_path, data):
print(colored("✓ Status: planning → in_progress (degraded)", Colors.GREEN))
_record_start_state(task_json_path, repo_root, " (degraded)")
run_task_hooks("after_start", task_json_path, repo_root)
return 0
@@ -127,11 +261,7 @@ def cmd_start(args: argparse.Namespace) -> int:
print(f"Source: {active.source}")
if task_json_path.is_file():
data = read_json(task_json_path)
if data and data.get("status") == "planning":
data["status"] = "in_progress"
if write_json(task_json_path, data):
print(colored("✓ Status: planning → in_progress", Colors.GREEN))
_record_start_state(task_json_path, repo_root)
print()
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
@@ -171,8 +301,20 @@ def cmd_current(args: argparse.Namespace) -> int:
if getattr(args, "json", False):
task_obj = None
read_error = None
if active.task_path:
data = read_json(repo_root / active.task_path / FILE_TASK_JSON) or {}
task_json_path = repo_root / active.task_path / FILE_TASK_JSON
data, reason = read_json_checked(task_json_path)
if data is None:
# Without this, a corrupt task.json emits null for every field
# — indistinguishable from a task whose fields really are null.
problem, hint = describe_json_read_failure(task_json_path, reason)
read_error = {
"file": str(task_json_path),
"reason": reason,
"message": f"{problem}. {hint}",
}
data = {}
task_obj = {
"dir": active.task_path,
"id": data.get("id") or data.get("name"),
@@ -183,11 +325,15 @@ def cmd_current(args: argparse.Namespace) -> int:
"branch": data.get("branch"),
"base_branch": data.get("base_branch"),
}
print(json.dumps({
payload = {
"current_task": task_obj,
"source": active.source,
"stale": active.stale,
}, ensure_ascii=False))
}
# Only present when the read failed, so the healthy shape is unchanged.
if read_error:
payload["error"] = read_error
print(json.dumps(payload, ensure_ascii=False))
return 0 if active.task_path else 1
if args.source:
@@ -242,7 +388,10 @@ def cmd_list(args: argparse.Namespace) -> int:
if as_json:
if filter_mine and not developer:
print(json.dumps({"error": "No developer set"}), file=sys.stderr)
print(
json.dumps({"error": "No developer set", "hint": DEVELOPER_HINT}),
file=sys.stderr,
)
return 1
items = []
@@ -270,6 +419,7 @@ def cmd_list(args: argparse.Namespace) -> int:
if filter_mine:
if not developer:
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
print(DEVELOPER_HINT, file=sys.stderr)
return 1
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
else:
@@ -378,20 +528,21 @@ def show_usage() -> None:
print("""Task Management Script
Usage:
python3 task.py create <title> Create new task directory
python3 task.py create <title> --package <pkg> Create task for a specific package
python3 task.py create <title> --parent <dir> Create task as child of parent
python3 task.py create <title> --no-start Create without making it active in this session
python3 task.py create <title> --description <desc> Create new task directory (both required, non-empty)
python3 task.py create <title> --description <desc> --package <pkg> Create task for a specific package
python3 task.py create <title> --description <desc> --parent <dir> Create task as child of parent
python3 task.py create <title> --description <desc> --no-start Create without making it active in this session
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
python3 task.py validate <dir> Validate jsonl files
python3 task.py list-context <dir> List jsonl entries
python3 task.py start <dir> Set active task
python3 task.py start <dir> Set active task; records the checked-out branch when unset
python3 task.py current [--source] Show active task
python3 task.py finish Clear active task
python3 task.py set-branch <dir> <branch> Set git branch
python3 task.py set-base-branch <dir> <branch> Set PR target branch
python3 task.py set-scope <dir> <scope> Set scope for PR title
python3 task.py set-meta <dir> <key> <value> Set/overwrite a task metadata key
python3 task.py rename <dir> <new-slug> Rename task, identity fields and references
python3 task.py archive <task-dir> Archive completed task
python3 task.py add-subtask <parent> <child> Link child task to parent
python3 task.py remove-subtask <parent> <child> Unlink child from parent
@@ -401,22 +552,38 @@ Usage:
Monorepo options:
--package <pkg> Package name (validated against config.yaml packages)
Rename options:
--dry-run Print the change set without writing anything
Archive options:
--no-commit Skip the auto git commit after archiving
--skip-branch-validation Archive despite missing or self-referential branch metadata.
Archive normally refuses a task with no `branch` when it has a
`base_branch` and the repo has a remote, or with
`branch == base_branch`; repair those with `set-branch` /
`set-base-branch` instead. Use this flag only for tasks that
were never PR-backed. A recorded branch that was merged and
deleted is only a warning and needs no flag.
List options:
--mine, -m Show only tasks assigned to current developer
--status, -s <s> Filter by status (planning, in_progress, review, completed)
--json Output machine-readable JSON (also available on `current`)
Examples:
python3 task.py create "Add login feature" --slug add-login
python3 task.py create "Add login feature" --slug add-login --package cli
python3 task.py create "Add login feature" --meta linear=ENG-123 --meta epic=auth
python3 task.py create "Child task" --slug child --parent .trellis/tasks/01-21-parent
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login --package cli
python3 task.py create "Add login feature" --description "Email + password sign-in" --meta linear=ENG-123 --meta epic=auth
python3 task.py create "Child task" --description "Session cookie handling" --slug child --parent .trellis/tasks/01-21-parent
python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
python3 task.py set-branch <dir> task/add-login
python3 task.py start .trellis/tasks/01-21-add-login
python3 task.py current --source
python3 task.py finish
python3 task.py rename add-login add-sso --dry-run # Preview the change set
python3 task.py rename add-login add-sso
python3 task.py archive add-login
python3 task.py archive add-login --skip-branch-validation # Task never had a branch of its own
python3 task.py add-subtask parent-task child-task # Link existing tasks
python3 task.py remove-subtask parent-task child-task
python3 task.py list # List all active tasks
@@ -469,11 +636,15 @@ def main() -> int:
# create
p_create = subparsers.add_parser("create", help="Create new task")
p_create.add_argument("title", help="Task title")
p_create.add_argument("title", help="Task title (required, non-empty)")
p_create.add_argument("--slug", "-s", help="Task slug without the MM-DD date prefix")
p_create.add_argument("--assignee", "-a", help="Assignee developer")
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
p_create.add_argument("--description", "-d", help="Task description")
p_create.add_argument(
"--description",
"-d",
help="Task description (required, non-empty — an empty one is refused at archive)",
)
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
p_create.add_argument("--package", help="Package name for monorepo projects")
p_create.add_argument(
@@ -490,6 +661,11 @@ def main() -> int:
action="store_true",
help="Create the task without making it active in this session",
)
p_create.add_argument(
"--force",
action="store_true",
help="Overwrite task.json when the task directory already exists",
)
# add-context
p_add = subparsers.add_parser("add-context", help="Add context entry")
@@ -509,6 +685,11 @@ def main() -> int:
# start
p_start = subparsers.add_parser("start", help="Set active task")
p_start.add_argument("dir", help="Task directory")
p_start.add_argument(
"--allow-empty-context",
action="store_true",
help="Start even when implement.jsonl / check.jsonl have no curated entries",
)
# current
p_current = subparsers.add_parser("current", help="Show active task")
@@ -541,10 +722,28 @@ def main() -> int:
p_setmeta.add_argument("key", help="Metadata key")
p_setmeta.add_argument("value", help="Metadata value")
# rename
p_rename = subparsers.add_parser("rename", help="Rename task and its references")
p_rename.add_argument("name", help="Task directory or name")
p_rename.add_argument("new_slug", help="New slug without the MM-DD date prefix")
p_rename.add_argument(
"--dry-run",
action="store_true",
help="Print the change set without writing anything",
)
# archive
p_archive = subparsers.add_parser("archive", help="Archive task")
p_archive.add_argument("name", help="Task directory or name")
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
p_archive.add_argument(
"--skip-branch-validation",
action="store_true",
help=(
"Archive even when branch metadata is missing or self-referential "
"(for tasks that were never PR-backed)"
),
)
# list
p_list = subparsers.add_parser("list", help="List tasks")
@@ -584,6 +783,7 @@ def main() -> int:
"set-base-branch": cmd_set_base_branch,
"set-scope": cmd_set_scope,
"set-meta": cmd_set_meta,
"rename": cmd_rename,
"archive": cmd_archive,
"add-subtask": cmd_add_subtask,
"remove-subtask": cmd_remove_subtask,