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