1875 行
71 KiB
Python
可执行文件
1875 行
71 KiB
Python
可执行文件
#!/usr/bin/env python3
|
|
"""
|
|
Task CRUD operations.
|
|
|
|
Provides:
|
|
ensure_tasks_dir - Ensure tasks directory exists
|
|
cmd_create - Create a new task
|
|
cmd_rename - Rename a task and every reference to it
|
|
cmd_archive - Archive completed task
|
|
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
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
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 (
|
|
INDEX_LOCK_RETRY_ATTEMPTS,
|
|
branch_exists_locally,
|
|
has_git_remote,
|
|
index_lock_path,
|
|
resolve_default_branch,
|
|
run_git,
|
|
run_git_retry_index_lock,
|
|
stderr_indicates_index_lock,
|
|
)
|
|
from .io import describe_json_read_failure, read_json_checked, write_json
|
|
from .log import Colors, colored
|
|
from .paths import (
|
|
DEVELOPER_HINT,
|
|
DIR_ARCHIVE,
|
|
DIR_TASKS,
|
|
DIR_WORKFLOW,
|
|
FILE_TASK_JSON,
|
|
generate_task_date_prefix,
|
|
get_developer,
|
|
get_repo_root,
|
|
get_tasks_dir,
|
|
)
|
|
from .safe_commit import (
|
|
print_gitignore_warning,
|
|
safe_archive_paths_to_add,
|
|
safe_git_add,
|
|
)
|
|
from .task_utils import (
|
|
archive_destination_for,
|
|
archive_task_complete,
|
|
find_task_by_name,
|
|
is_within_tasks_dir,
|
|
resolve_task_dir,
|
|
run_task_hooks,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Helper Functions
|
|
# =============================================================================
|
|
|
|
# Characters stripped before deciding whether a title / description is empty.
|
|
# This is deliberately the *union* of Python's str.strip() default set and
|
|
# ECMAScript's String.trim() set, because the two disagree: Python strips
|
|
# U+0085 (NEL) and U+001C-U+001F, which JS keeps; JS strips U+FEFF (BOM),
|
|
# which Python keeps. Stripping the union means anything create accepts is
|
|
# still non-empty after either side trims it, so a record cannot pass here and
|
|
# then be refused as empty by a JS pre-archive validator.
|
|
BLANK_CHARS = (
|
|
"\t\n\v\f\r" # U+0009-U+000D
|
|
"\x1c\x1d\x1e\x1f" # U+001C-U+001F separators (Python only)
|
|
" " # U+0020 space
|
|
"\x85" # U+0085 next line (Python only)
|
|
"\xa0" # U+00A0 no-break space
|
|
"\u1680" # ogham space mark
|
|
"\u2000\u2001\u2002\u2003\u2004\u2005" # en/em quad, en/em/three/four-per-em
|
|
"\u2006\u2007\u2008\u2009\u200a" # six-per-em .. hair space
|
|
"\u2028\u2029" # line / paragraph separator
|
|
"\u202f\u205f\u3000" # narrow nbsp, math space, ideographic space
|
|
"\ufeff" # zero-width no-break space / BOM (JS only)
|
|
)
|
|
|
|
|
|
def strip_blank(value: str | None) -> str:
|
|
"""Return ``value`` with :data:`BLANK_CHARS` trimmed from both ends."""
|
|
return (value or "").strip(BLANK_CHARS)
|
|
|
|
|
|
def _slugify(title: str) -> str:
|
|
"""Convert title to slug (only works with ASCII)."""
|
|
result = title.lower()
|
|
result = re.sub(r"[^a-z0-9]", "-", result)
|
|
result = re.sub(r"-+", "-", result)
|
|
result = result.strip("-")
|
|
return result
|
|
|
|
|
|
def ensure_tasks_dir(repo_root: Path) -> Path:
|
|
"""Ensure tasks directory exists."""
|
|
tasks_dir = get_tasks_dir(repo_root)
|
|
archive_dir = tasks_dir / "archive"
|
|
|
|
if not tasks_dir.exists():
|
|
tasks_dir.mkdir(parents=True)
|
|
print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
|
|
|
|
if not archive_dir.exists():
|
|
archive_dir.mkdir(parents=True)
|
|
|
|
return tasks_dir
|
|
|
|
|
|
def _find_archived_task_by_dir_name(tasks_dir: Path, dir_name: str) -> Path | None:
|
|
"""Find an archived task directory with the exact active-task dir name."""
|
|
archive_dir = tasks_dir / DIR_ARCHIVE
|
|
if not archive_dir.is_dir():
|
|
return None
|
|
|
|
for month_dir in sorted(archive_dir.iterdir()):
|
|
if not month_dir.is_dir():
|
|
continue
|
|
candidate = month_dir / dir_name
|
|
if candidate.is_dir():
|
|
return candidate
|
|
|
|
return None
|
|
|
|
|
|
def _repo_relative_path(path: Path, repo_root: Path) -> str:
|
|
"""Format a path relative to the repo root when possible."""
|
|
try:
|
|
return path.relative_to(repo_root).as_posix()
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def _report_read_failure(path: Path, reason: str | None) -> None:
|
|
"""Print why a JSON file could not be loaded, and what to do about it.
|
|
|
|
These commands are about to rewrite the file they just failed to read, so
|
|
this message is the last warning the user gets. Exiting non-zero with no
|
|
output at all leaves a parse error, a permissions error and an empty file
|
|
indistinguishable.
|
|
"""
|
|
problem, hint = describe_json_read_failure(path, reason)
|
|
print(colored(f"Error: {problem}", Colors.RED), file=sys.stderr)
|
|
print(hint, file=sys.stderr)
|
|
|
|
|
|
def _ensure_children_list(data: dict) -> list:
|
|
"""The task's `children` as a list, repaired in place if it is not one.
|
|
|
|
`.get(key, default)` returns the default only when the key is *absent*. A
|
|
task.json carrying `"children": null` — older format, or hand-edited —
|
|
yields None, and every caller below goes on to use the result as a list.
|
|
In `cmd_create` that raise lands *after* the new task.json is written,
|
|
leaving a task on disk its parent does not reference.
|
|
|
|
The repair is written into `data` rather than only returned, because the
|
|
unlink path removes a name it may not find and would otherwise persist the
|
|
malformed value untouched — no crash, but the next caller inherits it.
|
|
|
|
Anything that is not a list is discarded rather than coerced: a string
|
|
would iterate per character and a dict per key, each producing a plausible
|
|
child set that is not one.
|
|
"""
|
|
children = data.get("children")
|
|
if not isinstance(children, list):
|
|
children = []
|
|
data["children"] = children
|
|
return children
|
|
|
|
|
|
def _report_write_failure(path: Path) -> None:
|
|
"""Print that a JSON write failed. Writes are atomic, so nothing changed."""
|
|
print(colored(f"Error: Failed to write {path}", Colors.RED), file=sys.stderr)
|
|
print(
|
|
"The existing file is unchanged (writes are atomic). "
|
|
"Check permissions and free disk space, then retry.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Sub-agent platform detection + JSONL context files
|
|
# =============================================================================
|
|
|
|
# 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.
|
|
# 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",
|
|
".kiro",
|
|
".gemini",
|
|
".opencode",
|
|
".qoder",
|
|
".codebuddy",
|
|
".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"
|
|
|
|
|
|
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. 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
|
|
|
|
|
|
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
|
|
# =============================================================================
|
|
|
|
def cmd_create(args: argparse.Namespace) -> int:
|
|
"""Create a new task."""
|
|
repo_root = get_repo_root()
|
|
|
|
# Title and description are checked first, before ensure_tasks_dir and any
|
|
# other write, so a rejected create leaves the tree untouched. Both are
|
|
# required non-empty by pre-archive validation; catching that here costs
|
|
# one retyped command instead of blocking a PR hours later.
|
|
if not strip_blank(args.title):
|
|
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
|
print(
|
|
'Pass a non-empty <title> (whitespace only does not count): a task '
|
|
"with an empty title is refused at archive.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
description = strip_blank(getattr(args, "description", None))
|
|
if not description:
|
|
print(colored("Error: --description is required", Colors.RED), file=sys.stderr)
|
|
print(
|
|
'Pass --description "<what this task delivers>" (whitespace only does '
|
|
"not count): a task with an empty description is refused at archive.",
|
|
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):
|
|
# Single-repo: ignore --package, no package prefix
|
|
if package:
|
|
print(colored(f"Warning: --package ignored in single-repo project", Colors.YELLOW), file=sys.stderr)
|
|
package = None
|
|
elif package:
|
|
if not validate_package(package, repo_root):
|
|
packages = get_packages(repo_root)
|
|
available = ", ".join(sorted(packages.keys())) if packages else "(none)"
|
|
print(colored(f"Error: unknown package '{package}'. Available: {available}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
else:
|
|
# Inferred: default_package → None (no task.json yet for create)
|
|
package = resolve_package(repo_root=repo_root)
|
|
|
|
# Default assignee to current developer
|
|
assignee = args.assignee
|
|
if not assignee:
|
|
assignee = get_developer(repo_root)
|
|
if not assignee:
|
|
print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
|
|
print(DEVELOPER_HINT, file=sys.stderr)
|
|
return 1
|
|
|
|
ensure_tasks_dir(repo_root)
|
|
|
|
# Get current developer as creator
|
|
creator = get_developer(repo_root) or assignee
|
|
|
|
# Generate slug if not provided. A title-derived slug is sanitized by
|
|
# _slugify; an explicit --slug is not, so reject the characters that would
|
|
# let it escape the tasks directory once joined into the dir name.
|
|
slug = args.slug or _slugify(args.title)
|
|
if not slug:
|
|
print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
if args.slug and ("/" in slug or "\\" in slug or ".." in slug):
|
|
print(
|
|
colored(
|
|
f"Error: --slug must be a plain name without path separators or '..': {slug}",
|
|
Colors.RED,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# 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
|
|
|
|
# Resolve --parent before anything is created. An explicit --parent that
|
|
# cannot be linked is a failed request, not a warning: creating the task
|
|
# anyway leaves a half-specified relationship that reads as success.
|
|
parent_dir: Path | None = None
|
|
parent_data: dict | None = None
|
|
if args.parent:
|
|
parent_dir = resolve_task_dir(args.parent, repo_root)
|
|
if parent_dir is None:
|
|
print(colored(f"Error: Parent task not resolved: {args.parent}", Colors.RED), file=sys.stderr)
|
|
print("No task was created. Pass an existing task directory to --parent.", file=sys.stderr)
|
|
return 1
|
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
if not parent_json_path.is_file():
|
|
print(colored(f"Error: Parent task.json not found: {args.parent}", Colors.RED), file=sys.stderr)
|
|
print("No task was created. Pass an existing task directory to --parent.", file=sys.stderr)
|
|
return 1
|
|
parent_data, parent_reason = read_json_checked(parent_json_path)
|
|
if parent_data is None:
|
|
_report_read_failure(parent_json_path, parent_reason)
|
|
print("No task was created. Fix the parent task.json, then retry.", 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
|
|
|
|
archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, dir_name)
|
|
if archived_task_dir:
|
|
print(colored(f"Error: Task already archived: {dir_name}", Colors.RED), file=sys.stderr)
|
|
print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
|
|
print("Use a new slug if you intend to create a new task.", file=sys.stderr)
|
|
return 1
|
|
|
|
# Reusing a slug on the same day is an ordinary accident, and continuing
|
|
# would rewrite the existing task.json below — resetting status, children,
|
|
# parent, branch and meta, which nothing else can reconstruct. Fail like
|
|
# the archived-name collision above unless the caller asks for it.
|
|
if task_dir.exists():
|
|
if not getattr(args, "force", False):
|
|
print(colored(f"Error: Task already exists: {dir_name}", Colors.RED), file=sys.stderr)
|
|
print(f"Existing task at: {_repo_relative_path(task_dir, repo_root)}", file=sys.stderr)
|
|
print(
|
|
"Use a different --slug, or pass --force to overwrite its task.json.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
print(
|
|
colored(
|
|
f"Warning: --force: overwriting task.json in existing task: {dir_name}",
|
|
Colors.YELLOW,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
created_dir = False
|
|
else:
|
|
task_dir.mkdir(parents=True)
|
|
created_dir = True
|
|
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
|
|
# 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,
|
|
)
|
|
|
|
task_data = {
|
|
"id": slug,
|
|
"name": slug,
|
|
"title": args.title,
|
|
"description": description,
|
|
"status": "planning",
|
|
"dev_type": None,
|
|
"scope": None,
|
|
"package": package,
|
|
"priority": args.priority,
|
|
"creator": creator,
|
|
"assignee": assignee,
|
|
"createdAt": today,
|
|
"completedAt": None,
|
|
"branch": None,
|
|
"base_branch": base_branch,
|
|
"worktree_path": None,
|
|
"commit": None,
|
|
"pr_url": None,
|
|
"subtasks": [],
|
|
"children": [],
|
|
"parent": None,
|
|
"relatedFiles": [],
|
|
"notes": "",
|
|
"meta": meta,
|
|
}
|
|
|
|
# A directory without task.json is not a task: `list` hides it and every
|
|
# lifecycle command refuses it. Fail here rather than printing "Created
|
|
# task" and emitting the path for script chaining.
|
|
if not write_json(task_json_path, task_data):
|
|
_report_write_failure(task_json_path)
|
|
print(colored(f"No task was created: {dir_name}", Colors.RED), file=sys.stderr)
|
|
if created_dir:
|
|
try:
|
|
task_dir.rmdir()
|
|
except OSError:
|
|
print(
|
|
f"Leftover empty directory: {_repo_relative_path(task_dir, repo_root)}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
prd_path = task_dir / "prd.md"
|
|
if not prd_path.exists():
|
|
prd_path.write_text(
|
|
_default_prd_content(args.title, description),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Create empty implement.jsonl / check.jsonl for sub-agent-capable
|
|
# platforms. They stay empty until the agent curates real entries during
|
|
# planning — a placeholder row would read as unresolved scaffolding to
|
|
# `task.py validate` and to PR preflight, so the curation instructions go
|
|
# to the console below instead of into the files. Agent-less platforms
|
|
# (Kilo / Antigravity / Devin) skip this — they load specs via the
|
|
# trellis-before-dev skill instead of JSONL.
|
|
created_jsonl = False
|
|
if _has_subagent_platform(repo_root):
|
|
for jsonl_name in ("implement.jsonl", "check.jsonl"):
|
|
jsonl_path = task_dir / jsonl_name
|
|
if not jsonl_path.exists():
|
|
jsonl_path.write_text("", encoding="utf-8")
|
|
created_jsonl = True
|
|
|
|
# Establish the bidirectional link. Both sides were validated above, so a
|
|
# failure here is a write failure, not a bad argument.
|
|
if parent_dir is not None and parent_data is not None:
|
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
|
|
# Add child to parent's children list
|
|
parent_children = _ensure_children_list(parent_data)
|
|
if dir_name not in parent_children:
|
|
parent_children.append(dir_name)
|
|
parent_data["children"] = parent_children
|
|
if not write_json(parent_json_path, parent_data):
|
|
_report_write_failure(parent_json_path)
|
|
print(
|
|
f"The task exists at {_repo_relative_path(task_dir, repo_root)} but is NOT "
|
|
f"linked to {parent_dir.name}. Re-link with: task.py add-subtask "
|
|
f"{parent_dir.name} {dir_name}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# Set parent in child's task.json
|
|
task_data["parent"] = parent_dir.name
|
|
if not write_json(task_json_path, task_data):
|
|
_report_write_failure(task_json_path)
|
|
print(
|
|
f"Link is half-written: {parent_dir.name} now lists '{dir_name}' as a child, "
|
|
f"but the new task does not record its parent.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
|
|
# Auto-activate the new task so the per-turn breadcrumb fires planning
|
|
# state. Best-effort: gracefully degrade if no session identity (CLI run
|
|
# 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.
|
|
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:
|
|
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(" - 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 created_jsonl:
|
|
print(
|
|
" - Curate implement.jsonl / check.jsonl (created empty) as spec/research "
|
|
"manifests before task.py start when sub-agents need context:",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
' one JSON object per line — {"file": "<path>", "reason": "<why>"}; '
|
|
"spec/research docs only, no code paths",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
" list available specs: python3 .trellis/scripts/get_context.py --mode packages",
|
|
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
|
|
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
|
|
|
|
run_task_hooks("after_create", task_json_path, repo_root)
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Command: rename
|
|
# =============================================================================
|
|
|
|
# task.json fields that carry the task's own slug. The directory name carries
|
|
# it too, prefixed with the creation date; everything else that names the task
|
|
# (parent / children / subtasks in *other* tasks, jsonl paths) stores the full
|
|
# directory name instead.
|
|
RENAME_IDENTITY_FIELDS: tuple[str, ...] = ("id", "name")
|
|
|
|
_JSONL_NAMES: tuple[str, ...] = ("implement.jsonl", "check.jsonl")
|
|
|
|
|
|
@dataclass
|
|
class _RenamePlan:
|
|
"""Every change a rename would make, computed before anything is written.
|
|
|
|
``--dry-run`` and the real run render their output from this one structure,
|
|
so the printed change set cannot drift from the applied one.
|
|
"""
|
|
|
|
task_dir: Path
|
|
new_dir: Path
|
|
old_name: str
|
|
new_name: str
|
|
old_rel: str
|
|
new_rel: str
|
|
task_json_path: Path
|
|
task_data: dict
|
|
# (field, current value, new value) for each identity field that changes.
|
|
identity: list[tuple[str, object, str]] = field(default_factory=list)
|
|
# (jsonl path, changed line numbers, full new file text)
|
|
jsonl: list[tuple[Path, list[int], str]] = field(default_factory=list)
|
|
# (other task's task.json, rewritten data, labels of the changed refs)
|
|
backrefs: list[tuple[Path, dict, list[str]]] = field(default_factory=list)
|
|
# (file, line number) of old-name mentions elsewhere under .trellis/
|
|
reported: list[tuple[Path, int]] = field(default_factory=list)
|
|
# Task dirs whose task.json could not be read, so back-refs in them are
|
|
# neither rewritten nor ruled out.
|
|
unreadable: list[Path] = field(default_factory=list)
|
|
|
|
|
|
def _split_date_prefix(dir_name: str) -> tuple[str, str]:
|
|
"""Split ``MM-DD-slug`` into ``("MM-DD", "slug")``.
|
|
|
|
Returns ``("", dir_name)`` when there is no plausible date prefix, so a
|
|
hand-made directory name without one is renamed to the bare slug.
|
|
"""
|
|
m = re.match(r"^(\d{2})-(\d{2})-(.+)$", dir_name)
|
|
if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31:
|
|
return f"{m.group(1)}-{m.group(2)}", m.group(3)
|
|
return "", dir_name
|
|
|
|
|
|
def _validate_rename_slug(slug: str, date_prefix: str) -> str | None:
|
|
"""Return the slug body to rename to, or None after reporting the refusal.
|
|
|
|
Mirrors ``create``'s ``--slug`` handling: no path separators or ``..`` (the
|
|
slug is joined into a directory name), and a pasted-in date prefix is
|
|
normalized away rather than doubled — but a rename keeps the task's
|
|
*original* creation date, not today's.
|
|
"""
|
|
if not slug:
|
|
print(colored("Error: new slug is required", Colors.RED), file=sys.stderr)
|
|
return None
|
|
|
|
if "/" in slug or "\\" in slug or ".." in slug:
|
|
print(
|
|
colored(
|
|
f"Error: <new-slug> must be a plain name without path separators or '..': {slug}",
|
|
Colors.RED,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return None
|
|
|
|
slug_prefix, body = _split_date_prefix(slug)
|
|
if not slug_prefix:
|
|
return slug
|
|
if slug_prefix == date_prefix:
|
|
print(
|
|
colored(
|
|
f'warning: <new-slug> should not include the MM-DD prefix; normalized to "{body}"',
|
|
Colors.YELLOW,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return body
|
|
print(
|
|
colored(
|
|
f"Error: <new-slug> starts with a date prefix ({slug_prefix}-), but rename keeps "
|
|
f"the task's own creation date ({date_prefix}).",
|
|
Colors.RED,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
print(f"Pass only the slug body, e.g. {body}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def _plan_jsonl_rewrites(
|
|
task_dir: Path, old_rel: str, new_rel: str
|
|
) -> list[tuple[Path, list[int], str]]:
|
|
"""Plan rewrites of context entries pointing under the old task directory.
|
|
|
|
Matching on ``<old path>/`` rather than the bare path keeps a sibling task
|
|
whose name merely starts with this one's out of the rewrite.
|
|
"""
|
|
rewrites: list[tuple[Path, list[int], str]] = []
|
|
needle = f"{old_rel}/"
|
|
replacement = f"{new_rel}/"
|
|
|
|
for jsonl_name in _JSONL_NAMES:
|
|
jsonl_path = task_dir / jsonl_name
|
|
if not jsonl_path.is_file():
|
|
continue
|
|
try:
|
|
lines = jsonl_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
|
|
changed: list[int] = []
|
|
for index, line in enumerate(lines):
|
|
if needle in line:
|
|
lines[index] = line.replace(needle, replacement)
|
|
changed.append(index + 1)
|
|
if changed:
|
|
rewrites.append((jsonl_path, changed, "".join(lines)))
|
|
|
|
return rewrites
|
|
|
|
|
|
def _plan_backrefs(
|
|
tasks_dir: Path, task_dir: Path, old_name: str, new_name: str
|
|
) -> tuple[list[tuple[Path, dict, list[str]]], list[Path]]:
|
|
"""Plan back-reference rewrites in the other active tasks.
|
|
|
|
Returns ``(changes, unreadable)``. ``subtasks`` is the legacy spelling of
|
|
``children`` and is still carried in older task.json files, so both lists
|
|
are rewritten.
|
|
"""
|
|
changes: list[tuple[Path, dict, list[str]]] = []
|
|
unreadable: list[Path] = []
|
|
|
|
for candidate in sorted(tasks_dir.iterdir()):
|
|
if not candidate.is_dir() or candidate.name == DIR_ARCHIVE:
|
|
continue
|
|
if candidate == task_dir:
|
|
continue
|
|
json_path = candidate / FILE_TASK_JSON
|
|
if not json_path.is_file():
|
|
continue
|
|
|
|
data, _reason = read_json_checked(json_path)
|
|
if data is None:
|
|
unreadable.append(json_path)
|
|
continue
|
|
|
|
labels: list[str] = []
|
|
if data.get("parent") == old_name:
|
|
data["parent"] = new_name
|
|
labels.append("parent")
|
|
for list_field in ("children", "subtasks"):
|
|
values = data.get(list_field)
|
|
if not isinstance(values, list):
|
|
continue
|
|
for index, value in enumerate(values):
|
|
if value == old_name:
|
|
values[index] = new_name
|
|
labels.append(f"{list_field}[{index}]")
|
|
|
|
if labels:
|
|
changes.append((json_path, data, labels))
|
|
|
|
return changes, unreadable
|
|
|
|
|
|
def _plan_reported_refs(
|
|
repo_root: Path, task_dir: Path, rewritten: set[Path], old_name: str
|
|
) -> list[tuple[Path, int]]:
|
|
"""Find remaining mentions of the old task name elsewhere under .trellis/.
|
|
|
|
These are reported, never rewritten: journal entries and workflow prose
|
|
cite tasks in free text, and a blind substitution there is how a rename
|
|
turns into a diff nobody asked for. The boundary-anchored pattern keeps a
|
|
longer task name that merely contains this one out.
|
|
|
|
Runtime session pointers are excluded because they are not prose and they
|
|
*are* rewritten — see ``repoint_task_in_sessions``. Listing them here as
|
|
"not rewritten" would be a false statement about the one file whose
|
|
staleness actually breaks the next command.
|
|
"""
|
|
from .active_task import _runtime_sessions_dir
|
|
|
|
pattern = re.compile(
|
|
r"(?<![0-9A-Za-z_-])" + re.escape(old_name) + r"(?![0-9A-Za-z_-])"
|
|
)
|
|
hits: list[tuple[Path, int]] = []
|
|
trellis_dir = repo_root / DIR_WORKFLOW
|
|
sessions_dir = _runtime_sessions_dir(repo_root)
|
|
if not trellis_dir.is_dir():
|
|
return hits
|
|
|
|
for path in sorted(trellis_dir.rglob("*")):
|
|
if not path.is_file() or path in rewritten:
|
|
continue
|
|
if path == task_dir or task_dir in path.parents:
|
|
continue
|
|
if path.parent == sessions_dir:
|
|
continue
|
|
if any(
|
|
part == "__pycache__" or part.startswith(".backup-")
|
|
for part in path.relative_to(trellis_dir).parts[:-1]
|
|
):
|
|
continue
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
for lineno, line in enumerate(text.splitlines(), 1):
|
|
if pattern.search(line):
|
|
hits.append((path, lineno))
|
|
|
|
return hits
|
|
|
|
|
|
def _render_rename_plan(plan: _RenamePlan, repo_root: Path) -> list[str]:
|
|
"""Render the change set. Identical for --dry-run and the real run."""
|
|
lines = [
|
|
f"rename: {plan.old_name} -> {plan.new_name}",
|
|
f" dir: {plan.old_rel} -> {plan.new_rel}",
|
|
]
|
|
for field_name, old_value, new_value in plan.identity:
|
|
lines.append(f" task.json: {field_name}: {old_value} -> {new_value}")
|
|
for json_path, _data, labels in plan.backrefs:
|
|
rel = _repo_relative_path(json_path, repo_root)
|
|
for label in labels:
|
|
lines.append(
|
|
f" backref: {rel}: {label}: {plan.old_name} -> {plan.new_name}"
|
|
)
|
|
for jsonl_path, linenos, _text in plan.jsonl:
|
|
rel = _repo_relative_path(jsonl_path, repo_root)
|
|
for lineno in linenos:
|
|
lines.append(
|
|
f" jsonl: {rel}:{lineno}: {plan.old_rel}/ -> {plan.new_rel}/"
|
|
)
|
|
for path, lineno in plan.reported:
|
|
lines.append(
|
|
f" reported (not rewritten): {_repo_relative_path(path, repo_root)}:{lineno}"
|
|
)
|
|
lines.append(
|
|
f" sessions: any active-task pointer at {plan.old_rel} is repointed to {plan.new_rel}"
|
|
)
|
|
return lines
|
|
|
|
|
|
def _rename_interrupted(plan: _RenamePlan) -> None:
|
|
"""Explain how to finish a rename that stopped part-way through."""
|
|
print(
|
|
f"The task is still at {plan.old_rel} and was NOT moved. Every step up to "
|
|
f"this one is idempotent: fix the write failure, then run the same rename "
|
|
f"again to finish it.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def _apply_rename(plan: _RenamePlan, repo_root: Path) -> int:
|
|
"""Write the planned change set.
|
|
|
|
The directory move goes last on purpose. Everything before it is an
|
|
in-place edit that re-running the identical command recomputes as
|
|
already-done, so an interruption leaves a tree that the same command
|
|
finishes; a move-first ordering would instead leave the old name
|
|
unresolvable and the remaining edits to be made by hand.
|
|
"""
|
|
if plan.identity:
|
|
data = dict(plan.task_data)
|
|
for field_name, _old_value, new_value in plan.identity:
|
|
data[field_name] = new_value
|
|
if not write_json(plan.task_json_path, data):
|
|
_report_write_failure(plan.task_json_path)
|
|
print("Nothing was renamed.", file=sys.stderr)
|
|
return 1
|
|
|
|
for jsonl_path, _linenos, text in plan.jsonl:
|
|
try:
|
|
jsonl_path.write_text(text, encoding="utf-8")
|
|
except OSError as exc:
|
|
print(
|
|
colored(f"Error: Failed to write {jsonl_path}: {exc}", Colors.RED),
|
|
file=sys.stderr,
|
|
)
|
|
_rename_interrupted(plan)
|
|
return 1
|
|
|
|
for json_path, data, _labels in plan.backrefs:
|
|
if not write_json(json_path, data):
|
|
_report_write_failure(json_path)
|
|
_rename_interrupted(plan)
|
|
return 1
|
|
|
|
try:
|
|
plan.task_dir.rename(plan.new_dir)
|
|
except OSError as exc:
|
|
print(
|
|
colored(f"Error: Failed to move {plan.old_rel} -> {plan.new_rel}: {exc}", Colors.RED),
|
|
file=sys.stderr,
|
|
)
|
|
_rename_interrupted(plan)
|
|
return 1
|
|
|
|
# Last, and after the move: a session pointing at the old path would now
|
|
# resolve to a missing directory, so `current` would report the task stale
|
|
# and the context hook would inject nothing until the user ran `start`
|
|
# again. Archive clears these pointers because the task is leaving; rename
|
|
# moves them, because the task is still the one being worked on.
|
|
from .active_task import repoint_task_in_sessions
|
|
|
|
repoint_task_in_sessions(str(plan.task_dir), str(plan.new_dir), repo_root)
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_rename(args: argparse.Namespace) -> int:
|
|
"""Rename a task and every reference to it."""
|
|
repo_root = get_repo_root()
|
|
tasks_dir = get_tasks_dir(repo_root)
|
|
|
|
task_dir = resolve_task_dir(args.name, repo_root)
|
|
if task_dir is None or not task_dir.is_dir():
|
|
if task_dir is not None:
|
|
print(colored(f"Error: Task not found: {args.name}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
# Narrower than resolve_task_dir's containment: an archived task has left
|
|
# the active set, so its back-references are no longer maintained here.
|
|
if not is_within_tasks_dir(task_dir, repo_root):
|
|
print(
|
|
colored(
|
|
f"Error: refusing to rename '{args.name}': "
|
|
f"{task_dir} is not an active task under {tasks_dir}",
|
|
Colors.RED,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
old_name = task_dir.name
|
|
date_prefix, _old_slug = _split_date_prefix(old_name)
|
|
|
|
slug = _validate_rename_slug(args.new_slug, date_prefix)
|
|
if slug is None:
|
|
return 1
|
|
|
|
new_name = f"{date_prefix}-{slug}" if date_prefix else slug
|
|
if new_name == old_name:
|
|
print(
|
|
colored(f"Error: '{new_name}' is the task's current name", Colors.RED),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
new_dir = task_dir.parent / new_name
|
|
if new_dir.exists():
|
|
print(
|
|
colored(f"Error: Task already exists: {new_name}", Colors.RED),
|
|
file=sys.stderr,
|
|
)
|
|
print(f"Existing task at: {_repo_relative_path(new_dir, repo_root)}", file=sys.stderr)
|
|
print("Pick a different slug.", file=sys.stderr)
|
|
return 1
|
|
|
|
archived_task_dir = _find_archived_task_by_dir_name(tasks_dir, new_name)
|
|
if archived_task_dir:
|
|
print(
|
|
colored(f"Error: Task already archived: {new_name}", Colors.RED),
|
|
file=sys.stderr,
|
|
)
|
|
print(f"Archived at: {_repo_relative_path(archived_task_dir, repo_root)}", file=sys.stderr)
|
|
print("Pick a different slug.", file=sys.stderr)
|
|
return 1
|
|
|
|
task_json_path = task_dir / FILE_TASK_JSON
|
|
if not task_json_path.is_file():
|
|
print(
|
|
colored(f"Error: task.json not found at {task_dir}", Colors.RED),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
task_data, reason = read_json_checked(task_json_path)
|
|
if task_data is None:
|
|
_report_read_failure(task_json_path, reason)
|
|
print("Nothing was renamed.", file=sys.stderr)
|
|
return 1
|
|
|
|
plan = _RenamePlan(
|
|
task_dir=task_dir,
|
|
new_dir=new_dir,
|
|
old_name=old_name,
|
|
new_name=new_name,
|
|
old_rel=_repo_relative_path(task_dir, repo_root),
|
|
new_rel=_repo_relative_path(new_dir, repo_root),
|
|
task_json_path=task_json_path,
|
|
task_data=task_data,
|
|
)
|
|
plan.identity = [
|
|
(name, task_data.get(name), slug)
|
|
for name in RENAME_IDENTITY_FIELDS
|
|
if task_data.get(name) != slug
|
|
]
|
|
plan.jsonl = _plan_jsonl_rewrites(task_dir, plan.old_rel, plan.new_rel)
|
|
plan.backrefs, plan.unreadable = _plan_backrefs(
|
|
tasks_dir, task_dir, old_name, new_name
|
|
)
|
|
rewritten = {path for path, _linenos, _text in plan.jsonl}
|
|
rewritten.update(path for path, _data, _labels in plan.backrefs)
|
|
plan.reported = _plan_reported_refs(repo_root, task_dir, rewritten, old_name)
|
|
|
|
for line in _render_rename_plan(plan, repo_root):
|
|
print(line)
|
|
|
|
for json_path in plan.unreadable:
|
|
print(
|
|
colored(
|
|
f"Warning: {_repo_relative_path(json_path, repo_root)} could not be read; "
|
|
"any back-reference in it is left as-is.",
|
|
Colors.YELLOW,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
|
|
if getattr(args, "dry_run", False):
|
|
print(colored("Dry run: nothing was written.", Colors.YELLOW), file=sys.stderr)
|
|
return 0
|
|
|
|
rc = _apply_rename(plan, repo_root)
|
|
if rc != 0:
|
|
return rc
|
|
|
|
print(colored(f"Renamed: {old_name} -> {new_name}", Colors.GREEN), file=sys.stderr)
|
|
if plan.reported:
|
|
print(
|
|
colored(
|
|
f"{len(plan.reported)} reference(s) elsewhere under {DIR_WORKFLOW}/ "
|
|
"still name the old task; they are listed above and were not rewritten.",
|
|
Colors.YELLOW,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Command: archive
|
|
# =============================================================================
|
|
|
|
def _task_branch_field(data: dict, key: str) -> str:
|
|
"""Read a branch field as a trimmed string ("" when unset or not a string)."""
|
|
value = data.get(key)
|
|
return strip_blank(value) if isinstance(value, str) else ""
|
|
|
|
|
|
def _validate_branch_metadata(
|
|
data: dict,
|
|
task_name: str,
|
|
repo_root: Path,
|
|
skip: bool,
|
|
) -> bool:
|
|
"""Check branch metadata before the task leaves the active tree.
|
|
|
|
Returns False when archiving must stop. Archive is the last gate that sees
|
|
a task, so metadata nobody can reconstruct afterwards is refused here
|
|
rather than repaired by hand later (#399 follow-up).
|
|
|
|
"PR-backed" is deliberately pragmatic: a task carrying a base_branch in a
|
|
repo that has a remote was created expecting a PR, so a missing `branch`
|
|
means the metadata was never recorded — not that the work had no branch.
|
|
Local-only repos and tasks without a base_branch are left alone.
|
|
|
|
A recorded branch that no longer exists locally stays a warning: after a
|
|
merge the feature branch is normally deleted, and refusing to archive then
|
|
would be backwards.
|
|
"""
|
|
branch = _task_branch_field(data, "branch")
|
|
base_branch = _task_branch_field(data, "base_branch")
|
|
task_py = f"python3 {DIR_WORKFLOW}/scripts/task.py"
|
|
|
|
if branch and not branch_exists_locally(branch, repo_root):
|
|
print(
|
|
colored(
|
|
f"Warning: recorded branch '{branch}' no longer exists locally "
|
|
"(likely merged and deleted).",
|
|
Colors.YELLOW,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
|
|
if skip:
|
|
return True
|
|
|
|
if branch and base_branch and branch == base_branch:
|
|
print(
|
|
colored(
|
|
f"Error: refusing to archive '{task_name}': branch and base_branch "
|
|
f"are both '{branch}'. A PR cannot target its own branch, so this "
|
|
"metadata cannot describe the work that was merged.",
|
|
Colors.RED,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
print("Repair whichever field is wrong:", file=sys.stderr)
|
|
print(f" {task_py} set-branch {task_name} <feature-branch>", file=sys.stderr)
|
|
print(f" {task_py} set-base-branch {task_name} <target-branch>", file=sys.stderr)
|
|
print(
|
|
f" {task_py} archive {task_name} --skip-branch-validation"
|
|
" # only if this task was never PR-backed",
|
|
file=sys.stderr,
|
|
)
|
|
return False
|
|
|
|
if not branch and base_branch and has_git_remote(repo_root):
|
|
print(
|
|
colored(
|
|
f"Error: refusing to archive '{task_name}': no branch is recorded, "
|
|
f"but the task targets base_branch '{base_branch}' in a repo with a "
|
|
"remote — the branch it was built on was never written down.",
|
|
Colors.RED,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
print("Repair with:", file=sys.stderr)
|
|
print(f" {task_py} set-branch {task_name} <branch>", file=sys.stderr)
|
|
print(
|
|
f" {task_py} archive {task_name} --skip-branch-validation"
|
|
" # only if the work landed without a branch of its own",
|
|
file=sys.stderr,
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def cmd_archive(args: argparse.Namespace) -> int:
|
|
"""Archive completed task."""
|
|
repo_root = get_repo_root()
|
|
task_name = args.name
|
|
|
|
if not task_name:
|
|
print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
tasks_dir = get_tasks_dir(repo_root)
|
|
|
|
# Resolve task directory (supports task name, relative path, or absolute path)
|
|
task_dir = resolve_task_dir(task_name, repo_root)
|
|
|
|
if task_dir is None or not task_dir.is_dir():
|
|
if task_dir is None:
|
|
# resolve_task_dir already reported why; keep the archive-specific
|
|
# refusal so it reads the same as the is_within_tasks_dir guard.
|
|
print(colored(
|
|
f"Error: refusing to archive '{task_name}': "
|
|
f"it does not resolve to a task under {tasks_dir}",
|
|
Colors.RED), file=sys.stderr)
|
|
else:
|
|
print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
|
|
print("Active tasks:", file=sys.stderr)
|
|
# Import lazily to avoid circular dependency
|
|
from .tasks import iter_active_tasks
|
|
for t in iter_active_tasks(tasks_dir):
|
|
print(f" - {t.dir_name}/", file=sys.stderr)
|
|
return 1
|
|
|
|
# Refuse to archive anything that isn't a real task directly under
|
|
# .trellis/tasks/. resolve_task_dir keeps the target inside the tasks dir;
|
|
# this narrows it further to a direct child, so an already-archived task
|
|
# (.trellis/tasks/archive/<month>/<task>) is not archived a second time.
|
|
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
|
|
|
|
# Check the destination before anything below mutates task state. The
|
|
# mover refuses a collision too, but by then this command has already
|
|
# marked the task completed, re-parented its children and cleared the
|
|
# sessions pointing at it — all of which would have to be undone by hand.
|
|
archive_dest_check = archive_destination_for(task_dir)
|
|
if archive_dest_check.exists():
|
|
print(colored(
|
|
f"Error: refusing to archive '{task_name}': "
|
|
f"archive destination already exists: "
|
|
f"{_repo_relative_path(archive_dest_check, repo_root)}",
|
|
Colors.RED), file=sys.stderr)
|
|
print(f"Task remains at: {_repo_relative_path(task_dir, repo_root)}", file=sys.stderr)
|
|
print("Move or rename the existing archived task, then retry.", file=sys.stderr)
|
|
return 1
|
|
|
|
dir_name = task_dir.name
|
|
task_json_path = task_dir / FILE_TASK_JSON
|
|
|
|
# Update status before archiving
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
# Names of child task dirs whose task.json gets modified below; passed
|
|
# into safe_archive_paths_to_add so they're staged in this commit.
|
|
modified_children: list[str] = []
|
|
if task_json_path.is_file():
|
|
data, read_reason = read_json_checked(task_json_path)
|
|
if data is None:
|
|
# Archiving is still the right outcome for a task whose task.json
|
|
# is broken — but say so, or the missing "completed" status looks
|
|
# like the archive silently did half its job.
|
|
problem, _ = describe_json_read_failure(task_json_path, read_reason)
|
|
print(
|
|
colored(
|
|
f"Warning: {problem}; archiving without updating status/children.",
|
|
Colors.YELLOW,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
# Before any mutation: branch metadata is unrecoverable once the
|
|
# task leaves the active tree. Stale branches only warn.
|
|
if not _validate_branch_metadata(
|
|
data,
|
|
task_name,
|
|
repo_root,
|
|
getattr(args, "skip_branch_validation", False),
|
|
):
|
|
print(
|
|
f"Not archived: {_repo_relative_path(task_dir, repo_root)} is unchanged.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
data["status"] = "completed"
|
|
data["completedAt"] = today
|
|
if not write_json(task_json_path, data):
|
|
_report_write_failure(task_json_path)
|
|
print(
|
|
f"Not archived: {_repo_relative_path(task_dir, repo_root)} is unchanged. "
|
|
"Archiving a task still marked in progress would hide it from `list` "
|
|
"with the wrong status.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# Handle subtask relationships on archive.
|
|
# Keep this task in its parent's children list so progress
|
|
# counters (children_progress) stay consistent — children
|
|
# missing from the active set are treated as completed.
|
|
task_children = data.get("children", [])
|
|
|
|
# If this is a parent, clear parent field in all children
|
|
if task_children:
|
|
for child_name in task_children:
|
|
child_dir_path = find_task_by_name(child_name, tasks_dir)
|
|
if child_dir_path:
|
|
child_json = child_dir_path / FILE_TASK_JSON
|
|
if child_json.is_file():
|
|
child_data, child_reason = read_json_checked(child_json)
|
|
if child_data is None:
|
|
problem, _ = describe_json_read_failure(child_json, child_reason)
|
|
print(
|
|
colored(
|
|
f"Warning: {problem}; child '{child_dir_path.name}' "
|
|
"keeps its parent reference.",
|
|
Colors.YELLOW,
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
child_data["parent"] = None
|
|
if not write_json(child_json, child_data):
|
|
# Stop before the move: a child pointing at a
|
|
# parent that has left .trellis/tasks/ is a
|
|
# dangling reference nothing repairs later.
|
|
# Retrying is safe — every step so far is
|
|
# idempotent.
|
|
_report_write_failure(child_json)
|
|
print(
|
|
f"Not archived: {_repo_relative_path(task_dir, repo_root)} is "
|
|
f"marked completed but stays in place because child "
|
|
f"'{child_dir_path.name}' could not be unlinked. "
|
|
"Fix the child, then run archive again.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
modified_children.append(child_dir_path.name)
|
|
|
|
# Clear any session that still points at this task before the path moves.
|
|
from .active_task import clear_task_from_sessions
|
|
clear_task_from_sessions(str(task_dir), repo_root)
|
|
|
|
# Archive
|
|
result = archive_task_complete(task_dir, repo_root)
|
|
if "archived_to" in result:
|
|
archive_dest = Path(result["archived_to"])
|
|
year_month = archive_dest.parent.name
|
|
print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
|
|
|
|
# Auto-commit unless --no-commit
|
|
if not getattr(args, "no_commit", False):
|
|
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}")
|
|
|
|
# Run hooks with the archived path
|
|
archived_json = archive_dest / FILE_TASK_JSON
|
|
run_task_hooks("after_archive", archived_json, repo_root)
|
|
return 0
|
|
|
|
return 1
|
|
|
|
|
|
def _auto_commit_archive(
|
|
task_name: str,
|
|
repo_root: Path,
|
|
modified_children: list[str] | None = None,
|
|
) -> bool:
|
|
"""Stage Trellis-owned task paths and commit after archive.
|
|
|
|
Scoped narrowly to the archived task's source + destination paths
|
|
plus any child task dirs whose ``task.json`` was edited (parent →
|
|
children relationship update). Dirty changes in OTHER active task
|
|
dirs are NOT bundled into the archive commit.
|
|
|
|
If ``.gitignore`` blocks the paths, we warn + skip — we do NOT
|
|
retry with ``git add -f``. The warning explicitly forbids
|
|
``git add -f .trellis/`` (which would fan out to caches/backups)
|
|
and points users at ``session_auto_commit: false``.
|
|
|
|
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when
|
|
set to ``false``, this function returns immediately without
|
|
touching git (the archive directory move on disk is unaffected).
|
|
"""
|
|
if not get_session_auto_commit(repo_root):
|
|
print(
|
|
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
|
file=sys.stderr,
|
|
)
|
|
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 True
|
|
|
|
success, _, err = safe_git_add(paths, repo_root, retry_on_index_lock=True)
|
|
if not success:
|
|
if err and "ignored by" in err.lower():
|
|
print_gitignore_warning(paths)
|
|
elif stderr_indicates_index_lock(err):
|
|
_print_index_lock_warning(
|
|
"git add", task_name, repo_root, [*paths, source_rel]
|
|
)
|
|
else:
|
|
print(
|
|
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
|
file=sys.stderr,
|
|
)
|
|
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
|
|
# source task directory was moved away by `shutil.move`, so its files
|
|
# need an explicit `git rm --cached` to stage the deletions in this
|
|
# same commit — otherwise they sit as uncommitted "phantom deletes"
|
|
# against HEAD until something later picks them up.
|
|
#
|
|
# `--ignore-unmatch` makes this a no-op when the task was never tracked
|
|
# (e.g. archiving a task that lived only in working tree).
|
|
rc, _, err = run_git_retry_index_lock(
|
|
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
|
|
cwd=repo_root,
|
|
)
|
|
if rc != 0 and stderr_indicates_index_lock(err):
|
|
# Committing now would record the archived copy without the
|
|
# source-side deletes — a half-archived tree in history.
|
|
_print_index_lock_warning(
|
|
"git rm --cached", task_name, repo_root, [*paths, source_rel]
|
|
)
|
|
return not source_was_tracked
|
|
|
|
rc, _, _ = run_git(
|
|
["diff", "--cached", "--quiet", "--", *paths, source_rel],
|
|
cwd=repo_root,
|
|
)
|
|
if rc == 0:
|
|
print("[OK] No task changes to commit.", file=sys.stderr)
|
|
return True
|
|
|
|
commit_msg = f"chore(task): archive {task_name}"
|
|
# Commit with an explicit pathspec: a bare `git commit` would sweep any
|
|
# unrelated entries the developer had staged before archiving into the
|
|
# chore commit (#579). `source_rel` is included so the source-side
|
|
# deletions staged above land in the same commit.
|
|
rc, _, err = run_git_retry_index_lock(
|
|
["commit", "-m", commit_msg, "--", *paths, source_rel], cwd=repo_root
|
|
)
|
|
if rc == 0:
|
|
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
|
return True
|
|
elif stderr_indicates_index_lock(err):
|
|
_print_index_lock_warning(
|
|
"git commit", task_name, repo_root, [*paths, source_rel]
|
|
)
|
|
return not source_was_tracked
|
|
else:
|
|
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
|
|
return not source_was_tracked
|
|
|
|
|
|
def _print_index_lock_warning(
|
|
action: str, task_name: str, repo_root: Path, paths: list[str]
|
|
) -> None:
|
|
"""Report an archive auto-commit that gave up on a held index.lock.
|
|
|
|
The move itself already succeeded, so the state is consistent — the task
|
|
lives in archive/ and its changes are staged-or-not but never half of
|
|
both in a commit. Only the commit is outstanding, which the user (or an
|
|
agent reading the log) has to finish by hand.
|
|
|
|
``paths`` are the paths the auto-commit would have staged, so the manual
|
|
command keeps the same narrow scope — a blanket ``git add -A -- .trellis/``
|
|
would sweep dirty changes from other active tasks into the archive commit.
|
|
"""
|
|
lock = index_lock_path(repo_root)
|
|
print(
|
|
f"[WARN] {action} gave up after {INDEX_LOCK_RETRY_ATTEMPTS} attempts: "
|
|
f"another process is holding {lock}",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
"[WARN] The task was moved into archive/ on disk; only the commit is pending.",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
"[WARN] Close whatever holds the lock (an IDE git integration, a status",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
f"[WARN] daemon, another session), or delete {lock} if it is stale, then",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
f'[WARN] commit manually: git add -A -- {" ".join(paths)} && '
|
|
f'git commit -m "chore(task): archive {task_name}"',
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Command: add-subtask
|
|
# =============================================================================
|
|
|
|
def cmd_add_subtask(args: argparse.Namespace) -> int:
|
|
"""Link a child task to a parent task."""
|
|
repo_root = get_repo_root()
|
|
|
|
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
if parent_dir is None or child_dir is None:
|
|
return 1
|
|
|
|
if not parent_dir:
|
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
if not child_dir:
|
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
child_json_path = child_dir / FILE_TASK_JSON
|
|
|
|
if not parent_json_path.is_file():
|
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
if not child_json_path.is_file():
|
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
parent_data, parent_reason = read_json_checked(parent_json_path)
|
|
if parent_data is None:
|
|
_report_read_failure(parent_json_path, parent_reason)
|
|
return 1
|
|
child_data, child_reason = read_json_checked(child_json_path)
|
|
if child_data is None:
|
|
_report_read_failure(child_json_path, child_reason)
|
|
return 1
|
|
|
|
# Check if child already has a parent
|
|
existing_parent = child_data.get("parent")
|
|
if existing_parent:
|
|
print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
# Add child to parent's children list
|
|
parent_children = _ensure_children_list(parent_data)
|
|
child_dir_name = child_dir.name
|
|
if child_dir_name not in parent_children:
|
|
parent_children.append(child_dir_name)
|
|
parent_data["children"] = parent_children
|
|
|
|
# Set parent in child's task.json
|
|
child_data["parent"] = parent_dir.name
|
|
|
|
# Write both. A link is two files; if the second write fails silently the
|
|
# two sides disagree with no error, so check each and name the side that
|
|
# did land so the user knows what to undo.
|
|
if not write_json(parent_json_path, parent_data):
|
|
print(colored(f"Error: Failed to write parent task.json: {parent_json_path}", Colors.RED), file=sys.stderr)
|
|
print("No link was written.", file=sys.stderr)
|
|
return 1
|
|
if not write_json(child_json_path, child_data):
|
|
print(colored(f"Error: Failed to write child task.json: {child_json_path}", Colors.RED), file=sys.stderr)
|
|
print(
|
|
f"Link is half-written: {parent_json_path} now lists "
|
|
f"'{child_dir.name}' as a child, but the child does not record the parent.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Command: remove-subtask
|
|
# =============================================================================
|
|
|
|
def cmd_remove_subtask(args: argparse.Namespace) -> int:
|
|
"""Unlink a child task from a parent task."""
|
|
repo_root = get_repo_root()
|
|
|
|
parent_dir = resolve_task_dir(args.parent_dir, repo_root)
|
|
child_dir = resolve_task_dir(args.child_dir, repo_root)
|
|
if parent_dir is None or child_dir is None:
|
|
return 1
|
|
|
|
if not parent_dir:
|
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
if not child_dir:
|
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
parent_json_path = parent_dir / FILE_TASK_JSON
|
|
child_json_path = child_dir / FILE_TASK_JSON
|
|
|
|
if not parent_json_path.is_file():
|
|
print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
if not child_json_path.is_file():
|
|
print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
|
|
return 1
|
|
|
|
parent_data, parent_reason = read_json_checked(parent_json_path)
|
|
if parent_data is None:
|
|
_report_read_failure(parent_json_path, parent_reason)
|
|
return 1
|
|
child_data, child_reason = read_json_checked(child_json_path)
|
|
if child_data is None:
|
|
_report_read_failure(child_json_path, child_reason)
|
|
return 1
|
|
|
|
# Remove child from parent's children list
|
|
parent_children = _ensure_children_list(parent_data)
|
|
child_dir_name = child_dir.name
|
|
if child_dir_name in parent_children:
|
|
parent_children.remove(child_dir_name)
|
|
parent_data["children"] = parent_children
|
|
|
|
# Clear parent in child's task.json
|
|
child_data["parent"] = None
|
|
|
|
# Write both — see cmd_add_subtask: an unchecked second write leaves the
|
|
# two sides disagreeing with no error.
|
|
if not write_json(parent_json_path, parent_data):
|
|
print(colored(f"Error: Failed to write parent task.json: {parent_json_path}", Colors.RED), file=sys.stderr)
|
|
print("Nothing was unlinked.", file=sys.stderr)
|
|
return 1
|
|
if not write_json(child_json_path, child_data):
|
|
print(colored(f"Error: Failed to write child task.json: {child_json_path}", Colors.RED), file=sys.stderr)
|
|
print(
|
|
f"Unlink is half-written: {parent_json_path} no longer lists "
|
|
f"'{child_dir.name}', but the child still records the parent.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Command: set-branch
|
|
# =============================================================================
|
|
|
|
def cmd_set_branch(args: argparse.Namespace) -> int:
|
|
"""Set git branch for task."""
|
|
repo_root = get_repo_root()
|
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
if target_dir is None:
|
|
return 1
|
|
branch = args.branch
|
|
|
|
if not branch:
|
|
print(colored("Error: Missing arguments", Colors.RED))
|
|
print("Usage: python3 task.py set-branch <task-dir> <branch-name>")
|
|
return 1
|
|
|
|
if not target_dir:
|
|
# target_dir is None here, so it must not appear in the message. This
|
|
# is also the branch a ref pointing outside the repo lands in.
|
|
print(colored(f"Error: Task not found: {args.dir}", Colors.RED))
|
|
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, reason = read_json_checked(task_json)
|
|
if data is None:
|
|
_report_read_failure(task_json, reason)
|
|
return 1
|
|
|
|
data["branch"] = branch
|
|
if not write_json(task_json, data):
|
|
_report_write_failure(task_json)
|
|
return 1
|
|
|
|
print(colored(f"✓ Branch set to: {branch}", Colors.GREEN))
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Command: set-base-branch
|
|
# =============================================================================
|
|
|
|
def cmd_set_base_branch(args: argparse.Namespace) -> int:
|
|
"""Set the base branch (PR target) for task."""
|
|
repo_root = get_repo_root()
|
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
if target_dir is None:
|
|
return 1
|
|
base_branch = args.base_branch
|
|
|
|
if not base_branch:
|
|
print(colored("Error: Missing arguments", Colors.RED))
|
|
print("Usage: python3 task.py set-base-branch <task-dir> <base-branch>")
|
|
print("Example: python3 task.py set-base-branch <dir> develop")
|
|
print()
|
|
print("This sets the target branch for PR (the branch your feature will merge into).")
|
|
return 1
|
|
|
|
if not target_dir:
|
|
# target_dir is None here, so it must not appear in the message. This
|
|
# is also the branch a ref pointing outside the repo lands in.
|
|
print(colored(f"Error: Task not found: {args.dir}", Colors.RED))
|
|
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, reason = read_json_checked(task_json)
|
|
if data is None:
|
|
_report_read_failure(task_json, reason)
|
|
return 1
|
|
|
|
data["base_branch"] = base_branch
|
|
if not write_json(task_json, data):
|
|
_report_write_failure(task_json)
|
|
return 1
|
|
|
|
print(colored(f"✓ Base branch set to: {base_branch}", Colors.GREEN))
|
|
print(f" PR will target: {base_branch}")
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Command: set-scope
|
|
# =============================================================================
|
|
|
|
def cmd_set_scope(args: argparse.Namespace) -> int:
|
|
"""Set scope for PR title."""
|
|
repo_root = get_repo_root()
|
|
target_dir = resolve_task_dir(args.dir, repo_root)
|
|
if target_dir is None:
|
|
return 1
|
|
scope = args.scope
|
|
|
|
if not scope:
|
|
print(colored("Error: Missing arguments", Colors.RED))
|
|
print("Usage: python3 task.py set-scope <task-dir> <scope>")
|
|
return 1
|
|
|
|
if not target_dir:
|
|
# target_dir is None here, so it must not appear in the message. This
|
|
# is also the branch a ref pointing outside the repo lands in.
|
|
print(colored(f"Error: Task not found: {args.dir}", Colors.RED))
|
|
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, reason = read_json_checked(task_json)
|
|
if data is None:
|
|
_report_read_failure(task_json, reason)
|
|
return 1
|
|
|
|
data["scope"] = scope
|
|
if not write_json(task_json, data):
|
|
_report_write_failure(task_json)
|
|
return 1
|
|
|
|
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)
|
|
if target_dir is None:
|
|
return 1
|
|
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
|
|
|
|
if not target_dir:
|
|
# target_dir is None here, so it must not appear in the message. This
|
|
# is also the branch a ref pointing outside the repo lands in.
|
|
print(colored(f"Error: Task not found: {args.dir}", Colors.RED))
|
|
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, reason = read_json_checked(task_json)
|
|
if data is None:
|
|
_report_read_failure(task_json, reason)
|
|
return 1
|
|
|
|
meta = data.get("meta")
|
|
if not isinstance(meta, dict):
|
|
meta = {}
|
|
meta[key] = value
|
|
data["meta"] = meta
|
|
if not write_json(task_json, data):
|
|
_report_write_failure(task_json)
|
|
return 1
|
|
|
|
print(colored(f"✓ Meta set: {key} = {value}", Colors.GREEN))
|
|
return 0
|