升级 Trellis 并完成项目审查(07-26-trellis-upgrade-project-audit)
这个提交包含在:
+175
-41
@@ -12,9 +12,12 @@ Usage:
|
||||
<session content here>
|
||||
EOF
|
||||
|
||||
# Structured content (repeatable; a section with no bullets is omitted):
|
||||
python3 add_session.py --title "Title" --change "Did X" --test "Ran Y" --next-step "Do Z"
|
||||
|
||||
Branch resolution order:
|
||||
1. --branch CLI arg (explicit)
|
||||
2. task.json branch field (from active task)
|
||||
2. task.json branch field (from active task, if still exists)
|
||||
3. git branch --show-current (auto-detect)
|
||||
4. None (omitted gracefully)
|
||||
"""
|
||||
@@ -28,6 +31,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from common.paths import (
|
||||
DIR_TASKS,
|
||||
DIR_WORKFLOW,
|
||||
FILE_JOURNAL_PREFIX,
|
||||
get_repo_root,
|
||||
get_current_task,
|
||||
@@ -36,12 +41,14 @@ from common.paths import (
|
||||
)
|
||||
from common.developer import ensure_developer
|
||||
from common.git import run_git
|
||||
from common.log import Colors, colored
|
||||
from common.safe_commit import (
|
||||
print_gitignore_warning,
|
||||
safe_git_add,
|
||||
safe_trellis_paths_to_add,
|
||||
)
|
||||
from common.tasks import load_task
|
||||
from common.types import TaskInfo
|
||||
from common.config import (
|
||||
get_packages,
|
||||
get_session_auto_commit,
|
||||
@@ -124,6 +131,99 @@ def count_journal_files(dev_dir: Path, active_num: int) -> str:
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
def get_current_git_branch(repo_root: Path) -> str | None:
|
||||
"""Return the current checkout branch, or None for detached/non-git states."""
|
||||
rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
if rc != 0:
|
||||
return None
|
||||
detected = branch_out.strip()
|
||||
return detected or None
|
||||
|
||||
|
||||
def branch_ref_exists(repo_root: Path, branch: str) -> bool:
|
||||
"""Return True when branch exists locally or as the local origin ref."""
|
||||
for ref in (f"refs/heads/{branch}", f"refs/remotes/origin/{branch}"):
|
||||
rc, _, _ = run_git(["show-ref", "--verify", "--quiet", ref], cwd=repo_root)
|
||||
if rc == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_session_branch(
|
||||
repo_root: Path,
|
||||
cli_branch: str | None,
|
||||
task_data: TaskInfo | None,
|
||||
) -> str | None:
|
||||
"""Resolve journal branch without trusting stale task.json branch fields."""
|
||||
if cli_branch:
|
||||
return cli_branch
|
||||
|
||||
current_branch = get_current_git_branch(repo_root)
|
||||
raw_task_branch = task_data.raw.get("branch") if task_data else None
|
||||
task_branch = raw_task_branch.strip() if isinstance(raw_task_branch, str) else ""
|
||||
if not task_branch:
|
||||
return current_branch
|
||||
|
||||
if branch_ref_exists(repo_root, task_branch):
|
||||
return task_branch
|
||||
|
||||
if current_branch:
|
||||
print(
|
||||
f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; using current branch '{current_branch}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return current_branch
|
||||
|
||||
print(
|
||||
f"Warning: task.json branch '{task_branch}' no longer exists locally or as origin/{task_branch}; omitting branch.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def is_git_worktree(repo_root: Path) -> bool:
|
||||
"""Return True when repo_root is a linked worktree (not the main working tree).
|
||||
|
||||
Standard test: `git rev-parse --git-dir` (per-worktree) differs from
|
||||
`git rev-parse --git-common-dir` (shared across all worktrees) once both
|
||||
are resolved to absolute paths. In the main working tree these are the
|
||||
same directory.
|
||||
"""
|
||||
rc_dir, git_dir, _ = run_git(["rev-parse", "--git-dir"], cwd=repo_root)
|
||||
rc_common, git_common_dir, _ = run_git(
|
||||
["rev-parse", "--git-common-dir"], cwd=repo_root
|
||||
)
|
||||
if rc_dir != 0 or rc_common != 0:
|
||||
return False
|
||||
|
||||
git_dir_path = (repo_root / git_dir.strip()).resolve()
|
||||
git_common_dir_path = (repo_root / git_common_dir.strip()).resolve()
|
||||
return git_dir_path != git_common_dir_path
|
||||
|
||||
|
||||
def warn_if_parallel_worktree(repo_root: Path) -> None:
|
||||
"""Non-blocking note: index.md conflicts across parallel worktrees/branches
|
||||
are expected and safe. Only fires when running in a linked git worktree
|
||||
(not the main tree) with `session_auto_commit` enabled (#415 quick-fix tier).
|
||||
"""
|
||||
if not get_session_auto_commit(repo_root):
|
||||
return
|
||||
if not is_git_worktree(repo_root):
|
||||
return
|
||||
print(
|
||||
colored(
|
||||
"[NOTE] Running in a git worktree with session_auto_commit enabled: "
|
||||
"journal-*.md files auto-merge via .gitattributes, but index.md "
|
||||
"conflicts across parallel worktrees/branches are expected and safe "
|
||||
"to resolve by picking either side (task state lives in task.json, "
|
||||
"not index.md). See .trellis/spec/cli/backend/directory-structure.md "
|
||||
'("Workspace Journal Merge Behavior").',
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def create_new_journal_file(
|
||||
dev_dir: Path, num: int, developer: str, today: str, max_lines: int = 2000,
|
||||
) -> Path:
|
||||
@@ -143,15 +243,39 @@ def create_new_journal_file(
|
||||
return new_file
|
||||
|
||||
|
||||
def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "- ") -> str:
|
||||
"""Render a Markdown section as bullets, or "" when there is no content.
|
||||
|
||||
A section with zero provided values is omitted entirely from the
|
||||
rendered entry rather than falling back to a placeholder string.
|
||||
"""
|
||||
if not items:
|
||||
return ""
|
||||
bullets = "\n".join(f"{bullet_prefix}{item}" for item in items)
|
||||
return f"\n\n### {header}\n\n{bullets}"
|
||||
|
||||
|
||||
def _render_main_changes(changes: list[str], extra_content: str | None) -> str:
|
||||
"""Render the Main Changes section from --change bullets or freeform content."""
|
||||
if changes:
|
||||
return _render_bullet_section("Main Changes", changes)
|
||||
if extra_content:
|
||||
return f"\n\n### Main Changes\n\n{extra_content}"
|
||||
return ""
|
||||
|
||||
|
||||
def generate_session_content(
|
||||
session_num: int,
|
||||
title: str,
|
||||
commit: str,
|
||||
summary: str,
|
||||
extra_content: str,
|
||||
today: str,
|
||||
package: str | None = None,
|
||||
branch: str | None = None,
|
||||
changes: list[str] | None = None,
|
||||
extra_content: str | None = None,
|
||||
tests: list[str] | None = None,
|
||||
next_steps: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Generate session content."""
|
||||
if commit and commit != "-":
|
||||
@@ -166,6 +290,10 @@ def generate_session_content(
|
||||
package_line = f"\n**Package**: {package}" if package else ""
|
||||
branch_line = f"\n**Branch**: `{branch}`" if branch else ""
|
||||
|
||||
main_changes_section = _render_main_changes(changes or [], extra_content)
|
||||
testing_section = _render_bullet_section("Testing", tests or [], bullet_prefix="- [OK] ")
|
||||
next_steps_section = _render_bullet_section("Next Steps", next_steps or [])
|
||||
|
||||
return f"""
|
||||
|
||||
## Session {session_num}: {title}
|
||||
@@ -175,27 +303,15 @@ def generate_session_content(
|
||||
|
||||
### Summary
|
||||
|
||||
{summary}
|
||||
|
||||
### Main Changes
|
||||
|
||||
{extra_content}
|
||||
{summary}{main_changes_section}
|
||||
|
||||
### Git Commits
|
||||
|
||||
{commit_table}
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
{commit_table}{testing_section}
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
[OK] **Completed**{next_steps_section}
|
||||
"""
|
||||
|
||||
|
||||
@@ -319,12 +435,14 @@ def update_index(
|
||||
# =============================================================================
|
||||
|
||||
def _auto_commit_workspace(repo_root: Path) -> None:
|
||||
"""Stage Trellis-owned workspace + task paths and commit.
|
||||
"""Stage Trellis-owned workspace + current-task paths and commit.
|
||||
|
||||
Path scope is restricted to specific products (journal files, index.md,
|
||||
active task dirs, the archive subtree). We never `git add` the whole
|
||||
`.trellis/` tree, and if `.gitignore` blocks the specific paths we
|
||||
warn + skip — never retry with ``-f``.
|
||||
Path scope is restricted to specific products: the current developer's
|
||||
journal files + index.md, and ONLY the current task directory (resolved
|
||||
via ``get_current_task``). We never `git add` the whole `.trellis/` tree
|
||||
or iterate over all active task dirs (#303: parallel-window dirty task
|
||||
dirs must not be bundled into the session auto-commit). If `.gitignore`
|
||||
blocks the specific paths we warn + skip — never retry with ``-f``.
|
||||
|
||||
Honors ``session_auto_commit`` in ``.trellis/config.yaml``: when set to
|
||||
``false``, this function returns immediately without touching git
|
||||
@@ -338,7 +456,23 @@ def _auto_commit_workspace(repo_root: Path) -> None:
|
||||
return
|
||||
|
||||
commit_msg = get_session_commit_message(repo_root)
|
||||
paths = safe_trellis_paths_to_add(repo_root)
|
||||
# Resolve the current task so staging is scoped to its dir only. The ref
|
||||
# is ``.trellis/tasks/<name>`` (or under archive/) — pass the bare name.
|
||||
current = get_current_task(repo_root)
|
||||
if current:
|
||||
task_name = Path(current).name
|
||||
paths = safe_trellis_paths_to_add(repo_root, task_name=task_name)
|
||||
else:
|
||||
# Current task unknown (0 or >=2 parallel sessions — exactly the
|
||||
# parallel-window case #303 is about). Do NOT fall back to the wide
|
||||
# `tasks_dir.iterdir()` scan; that would re-leak other tasks' dirty
|
||||
# dirs into the session commit. Stage only the developer's journal/
|
||||
# index and skip every task dir.
|
||||
paths = [
|
||||
p
|
||||
for p in safe_trellis_paths_to_add(repo_root, task_name=None)
|
||||
if not p.startswith(f"{DIR_WORKFLOW}/{DIR_TASKS}/")
|
||||
]
|
||||
if not paths:
|
||||
print("[OK] No workspace changes to commit.", file=sys.stderr)
|
||||
return
|
||||
@@ -375,14 +509,18 @@ def _auto_commit_workspace(repo_root: Path) -> None:
|
||||
def add_session(
|
||||
title: str,
|
||||
commit: str = "-",
|
||||
summary: str = "(Add summary)",
|
||||
extra_content: str = "(Add details)",
|
||||
summary: str = "Session summary was not supplied.",
|
||||
changes: list[str] | None = None,
|
||||
extra_content: str | None = None,
|
||||
tests: list[str] | None = None,
|
||||
next_steps: list[str] | None = None,
|
||||
auto_commit: bool = True,
|
||||
package: str | None = None,
|
||||
branch: str | None = None,
|
||||
) -> int:
|
||||
"""Add a new session."""
|
||||
repo_root = get_repo_root()
|
||||
warn_if_parallel_worktree(repo_root)
|
||||
ensure_developer(repo_root)
|
||||
|
||||
developer = get_developer(repo_root)
|
||||
@@ -405,8 +543,9 @@ def add_session(
|
||||
new_session = current_session + 1
|
||||
|
||||
session_content = generate_session_content(
|
||||
new_session, title, commit, summary, extra_content, today, package,
|
||||
branch,
|
||||
new_session, title, commit, summary, today, package, branch,
|
||||
changes=changes, extra_content=extra_content, tests=tests,
|
||||
next_steps=next_steps,
|
||||
)
|
||||
content_lines = len(session_content.splitlines())
|
||||
|
||||
@@ -483,10 +622,13 @@ def main() -> int:
|
||||
)
|
||||
parser.add_argument("--title", required=True, help="Session title")
|
||||
parser.add_argument("--commit", default="-", help="Comma-separated commit hashes")
|
||||
parser.add_argument("--summary", default="(Add summary)", help="Brief summary")
|
||||
parser.add_argument("--summary", default="Session summary was not supplied.", help="Brief summary")
|
||||
parser.add_argument("--content-file", help="Path to file with detailed content")
|
||||
parser.add_argument("--package", help="Package name tag (e.g., cli, docs-site)")
|
||||
parser.add_argument("--branch", help="Branch name (auto-detected if omitted)")
|
||||
parser.add_argument("--change", action="append", help="Main Changes bullet (repeatable)")
|
||||
parser.add_argument("--test", action="append", help="Testing bullet (repeatable)")
|
||||
parser.add_argument("--next-step", action="append", help="Next Steps bullet (repeatable)")
|
||||
parser.add_argument("--no-commit", action="store_true",
|
||||
help="Skip auto-commit of workspace changes")
|
||||
parser.add_argument("--stdin", action="store_true",
|
||||
@@ -494,7 +636,7 @@ def main() -> int:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
extra_content = "(Add details)"
|
||||
extra_content: str | None = None
|
||||
if args.content_file:
|
||||
content_path = Path(args.content_file)
|
||||
if content_path.is_file():
|
||||
@@ -523,20 +665,12 @@ def main() -> int:
|
||||
task_package = task_data.package if task_data else None
|
||||
package = resolve_package(task_package, repo_root)
|
||||
|
||||
# Resolve branch: CLI → task.json → git auto-detect → None
|
||||
branch = args.branch
|
||||
|
||||
if not branch:
|
||||
if task_data and task_data.raw.get("branch"):
|
||||
branch = task_data.raw["branch"]
|
||||
else:
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
detected = branch_out.strip()
|
||||
if detected:
|
||||
branch = detected
|
||||
branch = resolve_session_branch(repo_root, args.branch, task_data)
|
||||
|
||||
return add_session(
|
||||
args.title, args.commit, args.summary, extra_content,
|
||||
args.title, args.commit, args.summary,
|
||||
changes=args.change, extra_content=extra_content, tests=args.test,
|
||||
next_steps=args.next_step,
|
||||
auto_commit=not args.no_commit,
|
||||
package=package,
|
||||
branch=branch,
|
||||
|
||||
@@ -43,6 +43,11 @@ _KNOWN_PLATFORMS = {
|
||||
"kiro",
|
||||
"copilot",
|
||||
"pi",
|
||||
"trae",
|
||||
"grok",
|
||||
"kimi",
|
||||
"zcode",
|
||||
"snow",
|
||||
}
|
||||
|
||||
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
@@ -57,6 +62,15 @@ _ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("kiro", ("KIRO_SESSION_ID",)),
|
||||
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
|
||||
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
|
||||
("trae", ("TRAE_SESSION_ID",)),
|
||||
# ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID).
|
||||
# Platform-scoped lookup (_iter_env_keys filters by platform name), so this
|
||||
# only fires when the resolver already detected "zcode" — no collision with
|
||||
# the claude entry above.
|
||||
("zcode", ("CLAUDE_SESSION_ID",)),
|
||||
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children.
|
||||
# TRELLIS_CONTEXT_ID remains the preferred override when present.
|
||||
("snow", ("SNOW_SESSION_ID",)),
|
||||
)
|
||||
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
|
||||
@@ -76,6 +90,12 @@ _ENV_PLATFORM_ALIASES = {
|
||||
"factory-ai": "droid",
|
||||
"github-copilot": "copilot",
|
||||
}
|
||||
# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode,
|
||||
# while later shell commands see only the shared env name and resolve it through
|
||||
# the Claude entry. Canonicalize both paths to one runtime filename.
|
||||
_CONTEXT_KEY_PLATFORM_ALIASES = {
|
||||
"zcode": "claude",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -189,6 +209,7 @@ def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None
|
||||
|
||||
|
||||
def _context_key(platform_name: str, kind: str, value: str) -> str:
|
||||
platform_name = _CONTEXT_KEY_PLATFORM_ALIASES.get(platform_name, platform_name)
|
||||
if kind == "transcript":
|
||||
return f"{platform_name}_transcript_{_hash_value(value)}"
|
||||
safe_value = _sanitize_key(value)
|
||||
@@ -380,15 +401,18 @@ def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
||||
def resolve_context_key(
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
*,
|
||||
allow_environment_context: bool = True,
|
||||
) -> str | None:
|
||||
"""Resolve a stable session/window context key, if one is available.
|
||||
|
||||
`TRELLIS_CONTEXT_ID` is an explicit context-key override used by CLI
|
||||
scripts and subprocesses. It does not store the task itself.
|
||||
"""
|
||||
override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
|
||||
if override:
|
||||
return _sanitize_key(override) or _hash_value(override)
|
||||
if allow_environment_context:
|
||||
override = _string_value(os.environ.get("TRELLIS_CONTEXT_ID"))
|
||||
if override:
|
||||
return _sanitize_key(override) or _hash_value(override)
|
||||
|
||||
data = _as_dict(platform_input)
|
||||
platform_name = _detect_platform(data, platform) if data or platform else None
|
||||
@@ -406,11 +430,12 @@ def resolve_context_key(
|
||||
if transcript_path:
|
||||
return _context_key(platform_name or "session", "transcript", transcript_path)
|
||||
|
||||
env_context_key = _lookup_env_context_key(platform_name)
|
||||
if env_context_key:
|
||||
return env_context_key
|
||||
if allow_environment_context:
|
||||
env_context_key = _lookup_env_context_key(platform_name)
|
||||
if env_context_key:
|
||||
return env_context_key
|
||||
|
||||
if platform_name in (None, "session", "cursor"):
|
||||
if allow_environment_context and platform_name in (None, "session", "cursor"):
|
||||
return _lookup_cursor_shell_ticket_context_key()
|
||||
return None
|
||||
|
||||
@@ -469,17 +494,24 @@ def resolve_active_task(
|
||||
repo_root: Path,
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
*,
|
||||
allow_single_session_fallback: bool = True,
|
||||
allow_environment_context: bool = True,
|
||||
) -> ActiveTask:
|
||||
"""Resolve the active task from session runtime state only.
|
||||
|
||||
A stale session task is returned as stale. Missing context identity or a
|
||||
missing/empty session context falls back to single-session inference: if
|
||||
exactly one session file exists in the runtime, return its task with
|
||||
source_type="session-fallback" — covers class-2 platform sub-agents (codex,
|
||||
copilot, gemini, qoder) that don't inherit the parent's session id. ≥2
|
||||
source_type="session-fallback" — covers pull-based platform sub-agents
|
||||
(copilot, gemini, qoder) that don't inherit the parent's session id. ≥2
|
||||
files or 0 files yield ActiveTask(None) — refuses to guess across windows.
|
||||
"""
|
||||
context_key = resolve_context_key(platform_input, platform)
|
||||
context_key = resolve_context_key(
|
||||
platform_input,
|
||||
platform,
|
||||
allow_environment_context=allow_environment_context,
|
||||
)
|
||||
if context_key:
|
||||
context = _read_json(_context_path(repo_root, context_key)) or {}
|
||||
task_ref = _string_value(context.get("current_task"))
|
||||
@@ -487,9 +519,10 @@ def resolve_active_task(
|
||||
if active:
|
||||
return active
|
||||
|
||||
fallback = _resolve_single_session_fallback(repo_root)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
if allow_single_session_fallback:
|
||||
fallback = _resolve_single_session_fallback(repo_root)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
|
||||
return ActiveTask(None, "none", context_key)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
CLI Adapter for Multi-Platform Support.
|
||||
|
||||
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Windsurf, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces.
|
||||
Abstracts differences between Claude Code, OpenCode, Cursor, iFlow, Codex, Kilo, Kiro Code, Gemini CLI, Antigravity, Devin, Qoder, CodeBuddy, GitHub Copilot, Factory Droid, and Pi Agent interfaces.
|
||||
|
||||
Supported platforms:
|
||||
- claude: Claude Code (default)
|
||||
@@ -13,12 +13,16 @@ Supported platforms:
|
||||
- kiro: Kiro Code (skills-based)
|
||||
- gemini: Gemini CLI
|
||||
- antigravity: Antigravity (workflow-based)
|
||||
- windsurf: Windsurf (workflow-based)
|
||||
- devin: Devin (formerly Windsurf; workflow-based)
|
||||
- qoder: Qoder
|
||||
- codebuddy: CodeBuddy
|
||||
- copilot: GitHub Copilot (VS Code)
|
||||
- droid: Factory Droid (commands-based)
|
||||
- pi: Pi Agent (extension-backed)
|
||||
- trae: Trae IDE (IDE-only, hooks-based)
|
||||
- omp: Oh My Pi
|
||||
- grok: Grok Build (pull-based skills/agents; no hook context injection)
|
||||
- kimi: Kimi Code (pull-based skills; commands delivered as skills; no hook context injection)
|
||||
|
||||
Usage:
|
||||
from common.cli_adapter import CLIAdapter
|
||||
@@ -47,12 +51,16 @@ Platform = Literal[
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"windsurf",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
"omp",
|
||||
"grok",
|
||||
"kimi",
|
||||
]
|
||||
|
||||
|
||||
@@ -97,7 +105,7 @@ class CLIAdapter:
|
||||
"""Get platform-specific config directory name.
|
||||
|
||||
Returns:
|
||||
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.windsurf', '.qoder', '.codebuddy', '.github/copilot', '.factory', or '.pi')
|
||||
Directory name ('.claude', '.opencode', '.cursor', '.iflow', '.codex', '.kilocode', '.kiro', '.gemini', '.agent', '.devin', '.qoder', '.codebuddy', '.github/copilot', '.factory', '.pi', or '.trae')
|
||||
"""
|
||||
if self.platform == "opencode":
|
||||
return ".opencode"
|
||||
@@ -115,8 +123,8 @@ class CLIAdapter:
|
||||
return ".gemini"
|
||||
elif self.platform == "antigravity":
|
||||
return ".agent"
|
||||
elif self.platform == "windsurf":
|
||||
return ".windsurf"
|
||||
elif self.platform == "devin":
|
||||
return ".devin"
|
||||
elif self.platform == "qoder":
|
||||
return ".qoder"
|
||||
elif self.platform == "codebuddy":
|
||||
@@ -127,6 +135,14 @@ class CLIAdapter:
|
||||
return ".factory"
|
||||
elif self.platform == "pi":
|
||||
return ".pi"
|
||||
elif self.platform == "trae":
|
||||
return ".trae"
|
||||
elif self.platform == "omp":
|
||||
return ".omp"
|
||||
elif self.platform == "grok":
|
||||
return ".grok"
|
||||
elif self.platform == "kimi":
|
||||
return ".kimi-code"
|
||||
else:
|
||||
return ".claude"
|
||||
|
||||
@@ -137,7 +153,7 @@ class CLIAdapter:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .windsurf, .qoder, .codebuddy, .github/copilot, .factory, or .pi)
|
||||
Path to config directory (.claude, .opencode, .cursor, .iflow, .codex, .kilocode, .kiro, .gemini, .agent, .devin, .qoder, .codebuddy, .github/copilot, .factory, .pi, or .trae)
|
||||
"""
|
||||
return project_root / self.config_dir_name
|
||||
|
||||
@@ -169,7 +185,7 @@ class CLIAdapter:
|
||||
Note:
|
||||
Cursor uses prefix naming: .cursor/commands/trellis-<name>.md
|
||||
Antigravity uses workflow directory: .agent/workflows/<name>.md
|
||||
Windsurf uses workflow directory: .windsurf/workflows/trellis-<name>.md
|
||||
Devin uses workflow directory: .devin/workflows/trellis-<name>.md
|
||||
Copilot uses prompt files: .github/prompts/<name>.prompt.md
|
||||
Pi uses prompt templates: .pi/prompts/trellis-<name>.md
|
||||
Claude/OpenCode use subdirectory: .claude/commands/trellis/<name>.md
|
||||
@@ -184,8 +200,31 @@ class CLIAdapter:
|
||||
filename = filename[:-3]
|
||||
return prompts_dir / f"trellis-{filename}.md"
|
||||
return prompts_dir / Path(*parts)
|
||||
# OMP and Grok: flat slash commands under .{platform}/commands/trellis-<name>.md
|
||||
if self.platform in ("omp", "grok"):
|
||||
commands_dir = self.get_config_dir(project_root) / "commands"
|
||||
if not parts:
|
||||
return commands_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return commands_dir / f"trellis-{filename}.md"
|
||||
return commands_dir / Path(*parts)
|
||||
|
||||
if self.platform == "windsurf":
|
||||
# Kimi: commands are skills under .kimi-code/skills/trellis-<name>/SKILL.md
|
||||
if self.platform == "kimi":
|
||||
skills_dir = self.get_config_dir(project_root) / "skills"
|
||||
if not parts:
|
||||
return skills_dir
|
||||
if len(parts) >= 2 and parts[0] == "trellis":
|
||||
filename = parts[-1]
|
||||
if filename.endswith(".md"):
|
||||
filename = filename[:-3]
|
||||
return skills_dir / f"trellis-{filename}" / "SKILL.md"
|
||||
return skills_dir / Path(*parts)
|
||||
|
||||
if self.platform == "devin":
|
||||
workflow_dir = self.get_config_dir(project_root) / "workflows"
|
||||
if not parts:
|
||||
return workflow_dir
|
||||
@@ -242,7 +281,7 @@ class CLIAdapter:
|
||||
Kiro: .kiro/skills/trellis-<name>/SKILL.md
|
||||
Gemini: .gemini/commands/trellis/<name>.toml
|
||||
Antigravity: .agent/workflows/<name>.md
|
||||
Windsurf: .windsurf/workflows/trellis-<name>.md
|
||||
Devin: .devin/workflows/trellis-<name>.md
|
||||
Pi: .pi/prompts/trellis-<name>.md
|
||||
Others: .{platform}/commands/trellis/<name>.md
|
||||
"""
|
||||
@@ -258,8 +297,8 @@ class CLIAdapter:
|
||||
return f".gemini/commands/trellis/{name}.toml"
|
||||
elif self.platform == "antigravity":
|
||||
return f".agent/workflows/{name}.md"
|
||||
elif self.platform == "windsurf":
|
||||
return f".windsurf/workflows/trellis-{name}.md"
|
||||
elif self.platform == "devin":
|
||||
return f".devin/workflows/trellis-{name}.md"
|
||||
elif self.platform == "kilo":
|
||||
return f".kilocode/workflows/{name}.md"
|
||||
elif self.platform == "copilot":
|
||||
@@ -268,6 +307,10 @@ class CLIAdapter:
|
||||
return f".factory/commands/trellis/{name}.md"
|
||||
elif self.platform == "pi":
|
||||
return f".pi/prompts/trellis-{name}.md"
|
||||
elif self.platform in ("omp", "grok"):
|
||||
return f"{self.config_dir_name}/commands/trellis-{name}.md"
|
||||
elif self.platform == "kimi":
|
||||
return f".kimi-code/skills/trellis-{name}/SKILL.md"
|
||||
else:
|
||||
return f"{self.config_dir_name}/commands/trellis/{name}.md"
|
||||
|
||||
@@ -293,7 +336,7 @@ class CLIAdapter:
|
||||
return {} # Gemini CLI doesn't have a non-interactive env var
|
||||
elif self.platform == "antigravity":
|
||||
return {}
|
||||
elif self.platform == "windsurf":
|
||||
elif self.platform == "devin":
|
||||
return {}
|
||||
elif self.platform == "qoder":
|
||||
return {}
|
||||
@@ -305,6 +348,14 @@ class CLIAdapter:
|
||||
return {}
|
||||
elif self.platform == "pi":
|
||||
return {}
|
||||
elif self.platform == "trae":
|
||||
return {}
|
||||
elif self.platform == "omp":
|
||||
return {}
|
||||
elif self.platform == "grok":
|
||||
return {}
|
||||
elif self.platform == "kimi":
|
||||
return {}
|
||||
else:
|
||||
return {"CLAUDE_NON_INTERACTIVE": "1"}
|
||||
|
||||
@@ -370,9 +421,9 @@ class CLIAdapter:
|
||||
raise ValueError(
|
||||
"Antigravity workflows are UI slash commands; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "windsurf":
|
||||
elif self.platform == "devin":
|
||||
raise ValueError(
|
||||
"Windsurf workflows are UI slash commands; CLI agent run is not supported."
|
||||
"Devin workflows are UI slash commands; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "qoder":
|
||||
cmd = ["qodercli", "-p", prompt]
|
||||
@@ -390,6 +441,21 @@ class CLIAdapter:
|
||||
)
|
||||
elif self.platform == "pi":
|
||||
cmd = ["pi", "-p", prompt]
|
||||
elif self.platform == "trae":
|
||||
raise ValueError(
|
||||
"Trae is IDE-only; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "omp":
|
||||
raise ValueError(
|
||||
"OMP uses native task tool for agent runs; CLI agent run is not supported."
|
||||
)
|
||||
elif self.platform == "grok":
|
||||
# Headless single-prompt; sub-agents use in-process spawn_subagent.
|
||||
cmd = ["grok", "-p", prompt, "--yolo"]
|
||||
elif self.platform == "kimi":
|
||||
# Headless single-prompt with auto-approval; sub-agents are the
|
||||
# built-in coder/explore/plan agents dispatched in-session.
|
||||
cmd = ["kimi", "-p", prompt, "--yolo"]
|
||||
|
||||
else: # claude
|
||||
cmd = ["claude", "-p"]
|
||||
@@ -436,9 +502,9 @@ class CLIAdapter:
|
||||
raise ValueError(
|
||||
"Antigravity workflows are UI slash commands; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "windsurf":
|
||||
elif self.platform == "devin":
|
||||
raise ValueError(
|
||||
"Windsurf workflows are UI slash commands; CLI resume is not supported."
|
||||
"Devin workflows are UI slash commands; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "qoder":
|
||||
return ["qodercli", "--resume", session_id]
|
||||
@@ -456,6 +522,18 @@ class CLIAdapter:
|
||||
)
|
||||
elif self.platform == "pi":
|
||||
return ["pi", "-c", session_id]
|
||||
elif self.platform == "trae":
|
||||
raise ValueError(
|
||||
"Trae is IDE-only; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "omp":
|
||||
raise ValueError(
|
||||
"OMP uses native task tool for agent runs; CLI resume is not supported."
|
||||
)
|
||||
elif self.platform == "grok":
|
||||
return ["grok", "-c"]
|
||||
elif self.platform == "kimi":
|
||||
return ["kimi", "--session", session_id]
|
||||
else:
|
||||
return ["claude", "--resume", session_id]
|
||||
|
||||
@@ -518,8 +596,8 @@ class CLIAdapter:
|
||||
return "gemini"
|
||||
elif self.platform == "antigravity":
|
||||
return "agy"
|
||||
elif self.platform == "windsurf":
|
||||
return "windsurf"
|
||||
elif self.platform == "devin":
|
||||
return "devin"
|
||||
elif self.platform == "qoder":
|
||||
return "qodercli"
|
||||
elif self.platform == "codebuddy":
|
||||
@@ -530,6 +608,14 @@ class CLIAdapter:
|
||||
return "droid"
|
||||
elif self.platform == "pi":
|
||||
return "pi"
|
||||
elif self.platform == "trae":
|
||||
return "trae"
|
||||
elif self.platform == "omp":
|
||||
return "omp"
|
||||
elif self.platform == "grok":
|
||||
return "grok"
|
||||
elif self.platform == "kimi":
|
||||
return "kimi"
|
||||
else:
|
||||
return "claude"
|
||||
|
||||
@@ -540,7 +626,15 @@ class CLIAdapter:
|
||||
Claude Code, OpenCode, iFlow, and Codex support CLI agent execution.
|
||||
Cursor is IDE-only and doesn't support CLI agents.
|
||||
"""
|
||||
return self.platform in ("claude", "opencode", "iflow", "codex", "pi")
|
||||
return self.platform in (
|
||||
"claude",
|
||||
"opencode",
|
||||
"iflow",
|
||||
"codex",
|
||||
"pi",
|
||||
"grok",
|
||||
"kimi",
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_agent_definition_file(self) -> bool:
|
||||
@@ -594,14 +688,21 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
|
||||
"""Get CLI adapter for the specified platform.
|
||||
|
||||
Args:
|
||||
platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')
|
||||
platform: Platform name ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or 'trae')
|
||||
|
||||
Returns:
|
||||
CLIAdapter instance
|
||||
|
||||
Raises:
|
||||
ValueError: If platform is not supported
|
||||
|
||||
Note:
|
||||
'windsurf' is accepted as a deprecated alias for 'devin' (Windsurf was
|
||||
renamed to Devin) and normalized before validation.
|
||||
"""
|
||||
# Deprecated alias: Windsurf was renamed to Devin.
|
||||
if platform == "windsurf":
|
||||
platform = "devin"
|
||||
if platform not in (
|
||||
"claude",
|
||||
"opencode",
|
||||
@@ -612,15 +713,19 @@ def get_cli_adapter(platform: str = "claude") -> CLIAdapter:
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"windsurf",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
"omp",
|
||||
"grok",
|
||||
"kimi",
|
||||
):
|
||||
raise ValueError(
|
||||
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', or 'pi')"
|
||||
f"Unsupported platform: {platform} (must be 'claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', 'omp', 'grok', or 'kimi')"
|
||||
)
|
||||
|
||||
return CLIAdapter(platform=platform) # type: ignore
|
||||
@@ -636,18 +741,23 @@ _ALL_PLATFORM_CONFIG_DIRS = (
|
||||
".kiro",
|
||||
".gemini",
|
||||
".agent",
|
||||
".windsurf",
|
||||
".devin",
|
||||
".windsurf", # deprecated: pre-rename Devin config dir (still a platform signal)
|
||||
".qoder",
|
||||
".codebuddy",
|
||||
".github/copilot",
|
||||
".factory",
|
||||
".pi",
|
||||
".trae",
|
||||
".omp",
|
||||
".grok",
|
||||
".kimi-code",
|
||||
)
|
||||
"""Platform-specific config directory names used by detect_platform exclusion
|
||||
checks. `.agents/skills/` is NOT listed here: it is a shared cross-platform
|
||||
layer (written by Codex, also consumed by Amp/Cline/Warp/etc. via the
|
||||
agentskills.io standard), not a single-platform signal. Its presence must not
|
||||
block detection of Kiro, Antigravity, Windsurf, or other platforms."""
|
||||
block detection of Kiro, Antigravity, Devin, or other platforms."""
|
||||
|
||||
|
||||
def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool:
|
||||
@@ -667,27 +777,33 @@ def detect_platform(project_root: Path) -> Platform:
|
||||
2. .opencode directory exists → opencode
|
||||
3. .iflow directory exists → iflow
|
||||
4. .cursor directory exists (without .claude) → cursor
|
||||
5. .codex exists and no other platform dirs → codex
|
||||
6. .kilocode directory exists → kilo
|
||||
7. .kiro/skills exists and no other platform dirs → kiro
|
||||
8. .gemini directory exists → gemini
|
||||
5. .gemini directory exists → gemini
|
||||
6. .codex exists and no other platform dirs → codex
|
||||
7. .kilocode directory exists → kilo
|
||||
8. .kiro/skills exists and no other platform dirs → kiro
|
||||
9. .agent/workflows exists and no other platform dirs → antigravity
|
||||
10. .windsurf/workflows exists and no other platform dirs → windsurf
|
||||
10. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin
|
||||
11. .codebuddy directory exists → codebuddy
|
||||
12. .qoder directory exists → qoder
|
||||
13. .pi directory exists → pi
|
||||
14. Default → claude
|
||||
13. .github/copilot directory exists → copilot
|
||||
14. .factory directory exists → droid
|
||||
15. .pi directory exists → pi
|
||||
16. .trae directory exists → trae
|
||||
17. Default → claude
|
||||
|
||||
Args:
|
||||
project_root: Project root directory
|
||||
|
||||
Returns:
|
||||
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'windsurf', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', or default 'claude')
|
||||
Detected platform ('claude', 'opencode', 'cursor', 'iflow', 'codex', 'kilo', 'kiro', 'gemini', 'antigravity', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', or default 'claude')
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check environment variable first
|
||||
env_platform = os.environ.get("TRELLIS_PLATFORM", "").lower()
|
||||
# Deprecated alias: Windsurf was renamed to Devin.
|
||||
if env_platform == "windsurf":
|
||||
env_platform = "devin"
|
||||
if env_platform in (
|
||||
"claude",
|
||||
"opencode",
|
||||
@@ -698,12 +814,16 @@ def detect_platform(project_root: Path) -> Platform:
|
||||
"kiro",
|
||||
"gemini",
|
||||
"antigravity",
|
||||
"windsurf",
|
||||
"devin",
|
||||
"qoder",
|
||||
"codebuddy",
|
||||
"copilot",
|
||||
"droid",
|
||||
"pi",
|
||||
"trae",
|
||||
"omp",
|
||||
"grok",
|
||||
"kimi",
|
||||
):
|
||||
return env_platform # type: ignore
|
||||
|
||||
@@ -749,13 +869,16 @@ def detect_platform(project_root: Path) -> Platform:
|
||||
):
|
||||
return "antigravity"
|
||||
|
||||
# Check for Windsurf workflow directory only when no other platform config exists
|
||||
# Check for Devin workflow directory only when no other platform config
|
||||
# exists. `.windsurf/workflows` is the legacy pre-rename path (still detected
|
||||
# as devin for back-compat until users migrate via `trellis update --migrate`).
|
||||
if (
|
||||
project_root / ".windsurf" / "workflows"
|
||||
).is_dir() and not _has_other_platform_dir(
|
||||
project_root, {".windsurf"}
|
||||
(project_root / ".devin" / "workflows").is_dir()
|
||||
or (project_root / ".windsurf" / "workflows").is_dir()
|
||||
) and not _has_other_platform_dir(
|
||||
project_root, {".devin", ".windsurf"}
|
||||
):
|
||||
return "windsurf"
|
||||
return "devin"
|
||||
|
||||
# Check for .codebuddy directory (CodeBuddy-specific)
|
||||
if (project_root / ".codebuddy").is_dir():
|
||||
@@ -777,6 +900,22 @@ def detect_platform(project_root: Path) -> Platform:
|
||||
if (project_root / ".pi").is_dir():
|
||||
return "pi"
|
||||
|
||||
# Check for .trae directory (Trae IDE-specific)
|
||||
if (project_root / ".trae").is_dir():
|
||||
return "trae"
|
||||
|
||||
# Check for .omp directory (OMP-specific)
|
||||
if (project_root / ".omp").is_dir():
|
||||
return "omp"
|
||||
|
||||
# Check for .grok directory (Grok Build-specific)
|
||||
if (project_root / ".grok").is_dir():
|
||||
return "grok"
|
||||
|
||||
# Check for .kimi-code directory (Kimi Code-specific)
|
||||
if (project_root / ".kimi-code").is_dir():
|
||||
return "kimi"
|
||||
|
||||
# Fallback: checkout only has the Codex shared-skills layer
|
||||
# (.agents/skills/trellis-* dirs) and no explicit platform config dir.
|
||||
# Happens on fresh clones where .codex/ is gitignored/absent but the
|
||||
|
||||
@@ -117,11 +117,12 @@ def _parse_yaml_block(
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = _strip_inline_comment(value).strip()
|
||||
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
|
||||
value = _unquote(value)
|
||||
current_list = None
|
||||
|
||||
if value:
|
||||
# key: value
|
||||
if value or was_quoted:
|
||||
# key: value (an explicit quoted "" is a value, not "no value")
|
||||
target[key] = value
|
||||
i += 1
|
||||
else:
|
||||
@@ -167,6 +168,7 @@ def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
|
||||
DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
|
||||
DEFAULT_MAX_JOURNAL_LINES = 2000
|
||||
DEFAULT_SESSION_AUTO_COMMIT = True
|
||||
DEFAULT_CODEX_DISPATCH_MODE = "auto"
|
||||
|
||||
CONFIG_FILE = "config.yaml"
|
||||
|
||||
@@ -243,6 +245,128 @@ def get_session_auto_commit(repo_root: Path | None = None) -> bool:
|
||||
return DEFAULT_SESSION_AUTO_COMMIT
|
||||
|
||||
|
||||
def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
|
||||
"""Return Codex dispatch mode.
|
||||
|
||||
Default is ``auto``, which dispatches Trellis sub-agents and uses native
|
||||
context injection with a child-side fallback. ``inline`` is an explicit
|
||||
opt-out. ``sub-agent`` remains a backwards-compatible alias for ``auto``.
|
||||
|
||||
Invalid explicit configuration falls back to ``inline`` rather than
|
||||
unexpectedly dispatching a sub-agent. This CLI-facing parser is the only
|
||||
place that emits a warning for invalid values; hook readers fail safely
|
||||
without producing per-turn warning noise.
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
codex = config.get("codex")
|
||||
if codex is None:
|
||||
return DEFAULT_CODEX_DISPATCH_MODE
|
||||
if not isinstance(codex, dict):
|
||||
print(
|
||||
f"[WARN] invalid codex config: {codex!r}; using inline",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return "inline"
|
||||
|
||||
raw = codex.get("dispatch_mode", DEFAULT_CODEX_DISPATCH_MODE)
|
||||
mode = str(raw).strip().lower()
|
||||
if mode in ("auto", "inline"):
|
||||
return mode
|
||||
if mode == "sub-agent":
|
||||
return "auto"
|
||||
print(
|
||||
f"[WARN] invalid codex.dispatch_mode value: {raw!r}; using inline",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return "inline"
|
||||
|
||||
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
|
||||
|
||||
|
||||
def get_context_injection_limits(repo_root: Path | None = None) -> dict[str, int]:
|
||||
"""Return sub-agent context injection byte limits.
|
||||
|
||||
Reads the ``context_injection:`` section of ``.trellis/config.yaml``:
|
||||
|
||||
context_injection:
|
||||
max_file_bytes: 32768
|
||||
max_artifact_bytes: 65536
|
||||
max_total_bytes: 131072
|
||||
|
||||
``0`` disables the corresponding limit. Missing keys use their default;
|
||||
invalid (non-int or negative) values fall back to the default for that
|
||||
key with a stderr warning.
|
||||
"""
|
||||
defaults = {
|
||||
"max_file_bytes": DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES,
|
||||
"max_artifact_bytes": DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES,
|
||||
"max_total_bytes": DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES,
|
||||
}
|
||||
|
||||
config = _load_config(repo_root)
|
||||
section = config.get("context_injection")
|
||||
if not isinstance(section, dict):
|
||||
return defaults
|
||||
|
||||
result = dict(defaults)
|
||||
for key, default_value in defaults.items():
|
||||
if key not in section:
|
||||
continue
|
||||
raw = section[key]
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
print(
|
||||
f"[WARN] invalid context_injection.{key} value: {raw!r}; "
|
||||
f"using default {default_value}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
if value < 0:
|
||||
print(
|
||||
f"[WARN] invalid context_injection.{key} value: {raw!r}; "
|
||||
f"using default {default_value}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis"
|
||||
|
||||
|
||||
def get_prompt_injection_config(repo_root: Path | None = None) -> dict[str, str]:
|
||||
"""Return per-turn prompt injection config.
|
||||
|
||||
Reads the ``prompt_injection:`` section of ``.trellis/config.yaml``:
|
||||
|
||||
prompt_injection:
|
||||
skip_keyword: "no-trellis" # "" disables the escape hatch entirely
|
||||
|
||||
``skip_keyword`` is the word-boundary, case-insensitive keyword that, when
|
||||
present in the user's prompt, makes the per-turn workflow-state injection
|
||||
emit nothing for that turn. Defaults to ``"no-trellis"``. A non-string
|
||||
value falls back to the default.
|
||||
"""
|
||||
defaults = {"skip_keyword": DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD}
|
||||
|
||||
config = _load_config(repo_root)
|
||||
section = config.get("prompt_injection")
|
||||
if not isinstance(section, dict):
|
||||
return defaults
|
||||
|
||||
result = dict(defaults)
|
||||
raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD)
|
||||
if isinstance(raw, str):
|
||||
result["skip_keyword"] = raw
|
||||
return result
|
||||
|
||||
|
||||
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
||||
"""Get hook commands for a lifecycle event.
|
||||
|
||||
|
||||
@@ -29,3 +29,39 @@ def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
return 1, "", str(e)
|
||||
|
||||
|
||||
def resolve_default_branch(repo_root: Path) -> str | None:
|
||||
"""Resolve the repository's default branch (origin/HEAD target).
|
||||
|
||||
Tries the local `refs/remotes/origin/HEAD` symbolic ref first (no
|
||||
network access), then falls back to `git remote show origin` (which
|
||||
may hit the network but also repairs a missing/stale symbolic-ref).
|
||||
Returns None when neither resolves, so callers can fall back to their
|
||||
own pre-existing behavior.
|
||||
"""
|
||||
rc, out, _ = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root)
|
||||
if rc == 0 and out.strip():
|
||||
return out.strip().rsplit("/", 1)[-1]
|
||||
|
||||
rc, out, _ = run_git(["remote", "show", "origin"], cwd=repo_root)
|
||||
if rc == 0:
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("HEAD branch:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
if branch and branch != "(unknown)":
|
||||
return branch
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def branch_exists_locally(branch: str, repo_root: Path) -> bool:
|
||||
"""Check whether a local branch ref exists in the repository."""
|
||||
if not branch:
|
||||
return False
|
||||
rc, _, _ = run_git(
|
||||
["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"],
|
||||
cwd=repo_root,
|
||||
)
|
||||
return rc == 0
|
||||
|
||||
@@ -8,6 +8,8 @@ for JSON file operations across all Trellis scripts.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -25,13 +27,35 @@ def read_json(path: Path) -> dict | None:
|
||||
def write_json(path: Path, data: dict) -> bool:
|
||||
"""Write dict to JSON file with pretty formatting.
|
||||
|
||||
The write is atomic: content goes to a temp file in the same directory
|
||||
and is then renamed over the target. A crash or Ctrl-C mid-write leaves
|
||||
the existing file intact rather than truncated, so a corrupted task.json
|
||||
can never make a task silently vanish from `task.py list`.
|
||||
|
||||
Returns True on success, False on error.
|
||||
"""
|
||||
payload = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
try:
|
||||
path.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
try:
|
||||
try:
|
||||
f = os.fdopen(fd, "w", encoding="utf-8")
|
||||
except OSError:
|
||||
# fdopen never took ownership of fd; close it ourselves.
|
||||
os.close(fd)
|
||||
raise
|
||||
with f:
|
||||
f.write(payload)
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
except OSError:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
@@ -58,7 +58,10 @@ TRELLIS_IGNORED_SUBPATHS = (
|
||||
)
|
||||
|
||||
|
||||
def safe_trellis_paths_to_add(repo_root: Path) -> list[str]:
|
||||
def safe_trellis_paths_to_add(
|
||||
repo_root: Path,
|
||||
task_name: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return the list of repo-relative paths the auto-commit should stage.
|
||||
|
||||
Only includes paths that exist on disk so callers don't pass non-existent
|
||||
@@ -68,12 +71,23 @@ def safe_trellis_paths_to_add(repo_root: Path) -> list[str]:
|
||||
Included:
|
||||
- .trellis/workspace/<developer>/journal-*.md
|
||||
- .trellis/workspace/<developer>/index.md
|
||||
- .trellis/tasks/<task-dir>/ (every active task directory)
|
||||
- .trellis/tasks/archive/ (whole archive subtree, if present)
|
||||
- .trellis/tasks/<task_name>/ (ONLY the current task dir when
|
||||
``task_name`` is passed; plus its archive location if the task
|
||||
already lives under archive/)
|
||||
|
||||
Excluded (intentionally — these must not be staged):
|
||||
- .trellis/.backup-*, .trellis/worktrees/,
|
||||
.trellis/.template-hashes.json, .trellis/.runtime/, .trellis/.cache/
|
||||
|
||||
Scope contract (see #303 / break-loop analysis): when ``task_name`` is
|
||||
passed, the task segment stages ONLY that task directory — it never walks
|
||||
``tasks_dir.iterdir()`` over all active tasks. This mirrors
|
||||
:func:`safe_archive_paths_to_add` and prevents dirty changes in OTHER
|
||||
parallel-window task dirs from being bundled into the session auto-commit.
|
||||
|
||||
Backwards-compat: with no ``task_name``, the function walks every active
|
||||
task directory (+ the archive subtree) the old wide way. New callers
|
||||
should always pass ``task_name``.
|
||||
"""
|
||||
paths: list[str] = []
|
||||
|
||||
@@ -93,20 +107,36 @@ def safe_trellis_paths_to_add(repo_root: Path) -> list[str]:
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md"
|
||||
)
|
||||
|
||||
# Active tasks: each direct child of tasks/ that is a directory and not
|
||||
# the archive root. The archive subtree is added as a single path below.
|
||||
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
|
||||
if tasks_dir.is_dir():
|
||||
for child in sorted(tasks_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name == DIR_ARCHIVE:
|
||||
continue
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||
if not tasks_dir.is_dir():
|
||||
return paths
|
||||
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
if archive_dir.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||
if task_name is not None:
|
||||
# Narrow scope — ONLY the current task directory (active or archived).
|
||||
# Never iterdir() all tasks: parallel-window dirty task dirs must not
|
||||
# leak into the session auto-commit.
|
||||
active_task = tasks_dir / task_name
|
||||
if active_task.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}")
|
||||
archived_task = tasks_dir / DIR_ARCHIVE / task_name
|
||||
if archived_task.is_dir():
|
||||
paths.append(
|
||||
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{task_name}"
|
||||
)
|
||||
return paths
|
||||
|
||||
# Legacy wide scope (no task_name): each direct child of tasks/ that is a
|
||||
# directory and not the archive root, plus the whole archive subtree.
|
||||
for child in sorted(tasks_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if child.name == DIR_ARCHIVE:
|
||||
continue
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child.name}")
|
||||
|
||||
archive_dir = tasks_dir / DIR_ARCHIVE
|
||||
if archive_dir.is_dir():
|
||||
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
|
||||
|
||||
return (
|
||||
f"Trellis update available: {current_version} -> {latest_version}, "
|
||||
f"run npm install -g {_PACKAGE_NAME}@latest"
|
||||
"run trellis update"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ Provides:
|
||||
Note:
|
||||
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
|
||||
are now seeded at ``task.py create`` time with a self-describing
|
||||
``_example`` line; the AI agent curates real entries during Phase 1.3 of
|
||||
the workflow. See ``.trellis/workflow.md`` Phase 1.3 for the current
|
||||
instructions.
|
||||
``_example`` line; the AI agent curates real entries during planning when
|
||||
the task needs sub-agent/spec context. See ``.trellis/workflow.md`` for the
|
||||
current planning artifact contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,10 +21,36 @@ import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_context_injection_limits
|
||||
from .git import branch_exists_locally
|
||||
from .io import read_json
|
||||
from .log import Colors, colored
|
||||
from .paths import get_repo_root
|
||||
from .paths import FILE_TASK_JSON, get_repo_root
|
||||
from .task_utils import resolve_task_dir
|
||||
|
||||
# Extensions that look like code rather than spec/research docs. Entries with
|
||||
# one of these extensions outside .trellis/spec/, docs/docs-site, or the
|
||||
# task's own directory get a hygiene warning in `task.py validate` — the
|
||||
# reader is a sub-agent, not a human, so code paths belong in the diff the
|
||||
# agent reads itself, not in implement.jsonl / check.jsonl.
|
||||
_CODE_FILE_EXTENSIONS = {
|
||||
".ts",
|
||||
".tsx",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".py",
|
||||
".go",
|
||||
".rs",
|
||||
".java",
|
||||
".rb",
|
||||
".c",
|
||||
".cc",
|
||||
".cpp",
|
||||
".h",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: add-context
|
||||
@@ -97,10 +123,26 @@ def cmd_validate(args: argparse.Namespace) -> int:
|
||||
print(f"Target dir: {target_dir}")
|
||||
print()
|
||||
|
||||
# Warn (don't fail validation) when the recorded branch is stale — it
|
||||
# was likely already merged and deleted (#399 item 2).
|
||||
task_json_path = target_dir / FILE_TASK_JSON
|
||||
if task_json_path.is_file():
|
||||
task_data = read_json(task_json_path)
|
||||
stored_branch = task_data.get("branch") if task_data else None
|
||||
if stored_branch and not branch_exists_locally(stored_branch, repo_root):
|
||||
print(
|
||||
colored(
|
||||
f"Warning: recorded branch '{stored_branch}' no longer exists locally "
|
||||
"(likely merged and deleted).",
|
||||
Colors.YELLOW,
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
total_errors = 0
|
||||
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
|
||||
jsonl_file = target_dir / jsonl_name
|
||||
errors = _validate_jsonl(jsonl_file, repo_root)
|
||||
errors = _validate_jsonl(jsonl_file, repo_root, target_dir)
|
||||
total_errors += errors
|
||||
|
||||
print()
|
||||
@@ -112,11 +154,33 @@ def cmd_validate(args: argparse.Namespace) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int:
|
||||
def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
|
||||
"""Whether a jsonl entry path is exempt from the code-file hygiene warning.
|
||||
|
||||
Exempt: spec docs (``.trellis/spec/``), documentation (``docs``,
|
||||
``docs-site``), and the task's own directory (execution plans, generated
|
||||
artifacts, etc. legitimately live there).
|
||||
"""
|
||||
posix_path = file_path.replace("\\", "/").lstrip("/")
|
||||
exempt_prefixes = (".trellis/spec/", "docs/", "docs-site/")
|
||||
if posix_path.startswith(exempt_prefixes):
|
||||
return True
|
||||
if task_rel and (posix_path == task_rel or posix_path.startswith(f"{task_rel}/")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
|
||||
"""Validate a single JSONL file.
|
||||
|
||||
Seed rows (no ``file`` field — typically ``{"_example": "..."}``) are
|
||||
skipped silently; they are self-describing comments, not real entries.
|
||||
|
||||
Beyond hard errors (missing file/dir, invalid JSON), this also prints
|
||||
non-blocking hygiene warnings (never counted in ``errors``, never change
|
||||
the exit code): entries that look like code files rather than
|
||||
spec/research docs, and entries whose file size exceeds the configured
|
||||
sub-agent context injection cap (``context_injection.max_file_bytes``).
|
||||
"""
|
||||
file_name = jsonl_file.name
|
||||
errors = 0
|
||||
@@ -125,6 +189,15 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int:
|
||||
print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
|
||||
return 0
|
||||
|
||||
task_rel = ""
|
||||
if task_dir is not None:
|
||||
try:
|
||||
task_rel = task_dir.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
task_rel = ""
|
||||
|
||||
max_file_bytes = get_context_injection_limits(repo_root).get("max_file_bytes", 0)
|
||||
|
||||
line_num = 0
|
||||
real_entries = 0
|
||||
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
||||
@@ -152,10 +225,33 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int:
|
||||
if not full_path.is_dir():
|
||||
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
else:
|
||||
if not full_path.is_file():
|
||||
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if not full_path.is_file():
|
||||
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
extension = Path(file_path).suffix.lower()
|
||||
if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning(
|
||||
file_path, task_rel
|
||||
):
|
||||
warning = (
|
||||
f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — "
|
||||
"implement/check.jsonl should reference spec/research docs; "
|
||||
"agents read code themselves"
|
||||
)
|
||||
print(f" {colored(warning, Colors.YELLOW)}")
|
||||
|
||||
if max_file_bytes:
|
||||
size = full_path.stat().st_size
|
||||
if size > max_file_bytes:
|
||||
warning = (
|
||||
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
|
||||
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
|
||||
"injection will truncate it"
|
||||
)
|
||||
print(f" {colored(warning, Colors.YELLOW)}")
|
||||
|
||||
if errors == 0:
|
||||
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
||||
|
||||
@@ -9,6 +9,7 @@ Provides:
|
||||
cmd_set_branch - Set git branch for task
|
||||
cmd_set_base_branch - Set PR target branch
|
||||
cmd_set_scope - Set scope for PR title
|
||||
cmd_set_meta - Set/overwrite a task metadata key
|
||||
cmd_add_subtask - Link child task to parent
|
||||
cmd_remove_subtask - Unlink child task from parent
|
||||
"""
|
||||
@@ -23,13 +24,14 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .config import (
|
||||
get_codex_dispatch_mode,
|
||||
get_packages,
|
||||
get_session_auto_commit,
|
||||
is_monorepo,
|
||||
resolve_package,
|
||||
validate_package,
|
||||
)
|
||||
from .git import run_git
|
||||
from .git import branch_exists_locally, resolve_default_branch, run_git
|
||||
from .io import read_json, write_json
|
||||
from .log import Colors, colored
|
||||
from .paths import (
|
||||
@@ -50,6 +52,7 @@ from .safe_commit import (
|
||||
from .task_utils import (
|
||||
archive_task_complete,
|
||||
find_task_by_name,
|
||||
is_within_tasks_dir,
|
||||
resolve_task_dir,
|
||||
run_task_hooks,
|
||||
)
|
||||
@@ -113,13 +116,13 @@ def _repo_relative_path(path: Path, repo_root: Path) -> str:
|
||||
|
||||
# Config directories of platforms that consume implement.jsonl / check.jsonl.
|
||||
# Keep in sync with src/types/ai-tools.ts AI_TOOLS entries — these are the
|
||||
# platforms listed in workflow.md's "agent-capable" Skill Routing block
|
||||
# (Class-1 hook-inject + Class-2 pull-based preludes). Kilo / Antigravity /
|
||||
# Windsurf are NOT in this list: they do not consume JSONL.
|
||||
# platforms listed in workflow.md's "agent-capable" Skill Routing block.
|
||||
# Codex is checked separately because explicit inline mode does not consume
|
||||
# JSONL. Kilo / Antigravity / Devin are NOT in this list either: they load
|
||||
# specs through skills instead of JSONL.
|
||||
_SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
|
||||
".claude",
|
||||
".cursor",
|
||||
".codex",
|
||||
".kiro",
|
||||
".gemini",
|
||||
".opencode",
|
||||
@@ -128,7 +131,13 @@ _SUBAGENT_CONFIG_DIRS: tuple[str, ...] = (
|
||||
".factory", # Factory Droid
|
||||
".github/copilot",
|
||||
".pi", # Pi Agent
|
||||
".trae", # Trae IDE
|
||||
".omp", # Oh My Pi
|
||||
".zcode", # ZCode
|
||||
".grok", # Grok Build
|
||||
".kimi-code", # Kimi Code
|
||||
)
|
||||
_CODEX_CONFIG_DIR = ".codex"
|
||||
|
||||
_SEED_EXAMPLE = (
|
||||
"Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. "
|
||||
@@ -141,13 +150,16 @@ _SEED_EXAMPLE = (
|
||||
def _has_subagent_platform(repo_root: Path) -> bool:
|
||||
"""Return True if any sub-agent-capable platform is configured.
|
||||
|
||||
Detected by probing well-known config directories at the repo root. Used
|
||||
only to decide whether ``task.py create`` should seed empty
|
||||
``implement.jsonl`` / ``check.jsonl`` files.
|
||||
Detected by probing well-known config directories at the repo root. Codex
|
||||
counts by default through ``codex.dispatch_mode: auto`` (including the
|
||||
legacy ``sub-agent`` alias); explicit inline mode loads context through
|
||||
skills, not JSONL.
|
||||
"""
|
||||
for config_dir in _SUBAGENT_CONFIG_DIRS:
|
||||
if (repo_root / config_dir).is_dir():
|
||||
return True
|
||||
if (repo_root / _CODEX_CONFIG_DIR).is_dir():
|
||||
return get_codex_dispatch_mode(repo_root) == "auto"
|
||||
return False
|
||||
|
||||
|
||||
@@ -162,6 +174,52 @@ def _write_seed_jsonl(path: Path) -> None:
|
||||
path.write_text(json.dumps(seed, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None:
|
||||
"""Parse repeatable ``--meta key=value`` pairs into a dict.
|
||||
|
||||
Returns ``None`` (after printing an error naming the bad value) on the
|
||||
first malformed pair: missing ``=`` or an empty key. Values are stored
|
||||
as-is (strings, no nesting, no type coercion).
|
||||
"""
|
||||
meta: dict[str, str] = {}
|
||||
for pair in pairs or []:
|
||||
key, sep, value = pair.partition("=")
|
||||
if not sep or not key:
|
||||
print(
|
||||
colored(f"Error: malformed --meta value '{pair}' (expected key=value)", Colors.RED),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
meta[key] = value
|
||||
return meta
|
||||
|
||||
|
||||
def _default_prd_content(title: str, description: str | None = None) -> str:
|
||||
"""Return the default PRD skeleton created with every task."""
|
||||
goal = (description or "").strip() or "TBD."
|
||||
heading = title.strip() or "Untitled task"
|
||||
return f"""# {heading}
|
||||
|
||||
## Goal
|
||||
|
||||
{goal}
|
||||
|
||||
## Requirements
|
||||
|
||||
- TBD
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TBD
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
|
||||
- Lightweight tasks can remain PRD-only.
|
||||
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
|
||||
"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: create
|
||||
# =============================================================================
|
||||
@@ -174,6 +232,11 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Validate --meta (CLI source: fail-fast, before any directory is created)
|
||||
meta = _parse_meta_pairs(getattr(args, "meta", None))
|
||||
if meta is None:
|
||||
return 1
|
||||
|
||||
# Validate --package (CLI source: fail-fast)
|
||||
package: str | None = getattr(args, "package", None)
|
||||
if not is_monorepo(repo_root):
|
||||
@@ -213,6 +276,34 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
# Create task directory with MM-DD-slug format
|
||||
tasks_dir = get_tasks_dir(repo_root)
|
||||
date_prefix = generate_task_date_prefix()
|
||||
|
||||
# Guard against date-prefixed --slug (e.g. a full task dir name pasted in),
|
||||
# which would otherwise produce MM-DD-MM-DD-slug (issue #377). Only an
|
||||
# explicit --slug is guarded; title-derived slugs are left untouched.
|
||||
if args.slug:
|
||||
m = re.match(r"^(\d{2})-(\d{2})-(.+)$", slug)
|
||||
if m and 1 <= int(m.group(1)) <= 12 and 1 <= int(m.group(2)) <= 31:
|
||||
slug_prefix = f"{m.group(1)}-{m.group(2)}"
|
||||
if slug_prefix == date_prefix:
|
||||
slug = m.group(3)
|
||||
print(
|
||||
colored(
|
||||
f'warning: --slug should not include the MM-DD prefix; normalized to "{slug}"',
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
colored(
|
||||
f"Error: --slug starts with a date prefix ({slug_prefix}-), but task.py create always uses today's date ({date_prefix}).",
|
||||
Colors.RED,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"Pass only the slug body, e.g. --slug {m.group(3)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dir_name = f"{date_prefix}-{slug}"
|
||||
task_dir = tasks_dir / dir_name
|
||||
task_json_path = task_dir / FILE_TASK_JSON
|
||||
@@ -231,15 +322,48 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Record current branch as base_branch (PR target)
|
||||
# Record the PR target branch. Prefer the repo's actual default branch
|
||||
# (origin/HEAD) so creating a task from a feature branch doesn't
|
||||
# mis-stamp that feature branch as the PR target (#399 item 1). Falls
|
||||
# back to the checked-out branch when the default can't be resolved
|
||||
# (no remote configured, offline, etc.) — the pre-existing behavior.
|
||||
# --base-branch lets the caller override both when neither is correct.
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
current_branch = branch_out.strip() or "main"
|
||||
explicit_base_branch: str | None = getattr(args, "base_branch", None)
|
||||
if explicit_base_branch:
|
||||
base_branch = explicit_base_branch
|
||||
else:
|
||||
resolved_base_branch = resolve_default_branch(repo_root)
|
||||
if resolved_base_branch:
|
||||
base_branch = resolved_base_branch
|
||||
else:
|
||||
base_branch = current_branch
|
||||
print(
|
||||
colored(
|
||||
f"warning: could not resolve the repository's default branch "
|
||||
f"(no remote configured, offline, etc.); stamping base_branch as "
|
||||
f"the checked-out branch '{base_branch}'. Pass --base-branch to override.",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
description = (args.description or "").strip()
|
||||
if not description.strip():
|
||||
print(
|
||||
colored(
|
||||
"warning: task description is empty; pass --description to improve search and later audits.",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
task_data = {
|
||||
"id": slug,
|
||||
"name": slug,
|
||||
"title": args.title,
|
||||
"description": args.description or "",
|
||||
"description": description,
|
||||
"status": "planning",
|
||||
"dev_type": None,
|
||||
"scope": None,
|
||||
@@ -250,7 +374,7 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
"createdAt": today,
|
||||
"completedAt": None,
|
||||
"branch": None,
|
||||
"base_branch": current_branch,
|
||||
"base_branch": base_branch,
|
||||
"worktree_path": None,
|
||||
"commit": None,
|
||||
"pr_url": None,
|
||||
@@ -259,14 +383,21 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
"parent": None,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {},
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
write_json(task_json_path, task_data)
|
||||
|
||||
prd_path = task_dir / "prd.md"
|
||||
if not prd_path.exists():
|
||||
prd_path.write_text(
|
||||
_default_prd_content(args.title, description),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Seed implement.jsonl / check.jsonl for sub-agent-capable platforms.
|
||||
# Agent curates real entries in Phase 1.3 (see .trellis/workflow.md).
|
||||
# Agent-less platforms (Kilo / Antigravity / Windsurf) skip this — they
|
||||
# Agent curates real entries during planning when the task needs them.
|
||||
# Agent-less platforms (Kilo / Antigravity / Devin) skip this — they
|
||||
# load specs via the trellis-before-dev skill instead of JSONL.
|
||||
seeded_jsonl = False
|
||||
if _has_subagent_platform(repo_root):
|
||||
@@ -303,30 +434,70 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
# outside an AI session) — the task is still created, the user can run
|
||||
# task.py start later. Pointer is session-scoped so this never affects
|
||||
# other AI sessions.
|
||||
try:
|
||||
from .active_task import resolve_context_key, set_active_task
|
||||
if resolve_context_key():
|
||||
if getattr(args, "no_start", False):
|
||||
print(
|
||||
colored(
|
||||
"Skipped session activation (--no-start); run task.py start when ready.",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from .active_task import resolve_context_key, set_active_task
|
||||
except Exception as exc:
|
||||
print(
|
||||
colored(f"Warning: session activation unavailable (import failed: {exc})", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
rel_dir = task_dir.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
rel_dir = str(task_dir)
|
||||
set_active_task(rel_dir, repo_root)
|
||||
except Exception:
|
||||
pass
|
||||
context_key = resolve_context_key()
|
||||
except Exception as exc:
|
||||
print(
|
||||
colored(f"Warning: session activation failed (context resolution: {exc})", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
# No session identity is the normal CLI-outside-an-AI-session
|
||||
# case (see comment above) — stay silent, not a failure.
|
||||
if context_key:
|
||||
try:
|
||||
rel_dir = task_dir.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
rel_dir = str(task_dir)
|
||||
try:
|
||||
active = set_active_task(rel_dir, repo_root)
|
||||
except Exception as exc:
|
||||
print(
|
||||
colored(f"Warning: session activation failed (pointer persistence: {exc})", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
if active:
|
||||
print(
|
||||
colored(f"Activated task for this session: {active.task_path}", Colors.GREEN),
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"Source: {active.source}", file=sys.stderr)
|
||||
else:
|
||||
print(
|
||||
colored("Warning: session activation failed (no pointer returned)", Colors.YELLOW),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
||||
print(" 1. Create prd.md with requirements", file=sys.stderr)
|
||||
print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
|
||||
print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
|
||||
print(" - Complex task: add design.md and implement.md before task.py start", file=sys.stderr)
|
||||
if seeded_jsonl:
|
||||
print(
|
||||
" 2. Curate implement.jsonl / check.jsonl (spec + research files only — "
|
||||
"see .trellis/workflow.md Phase 1.3)",
|
||||
" - Curate implement.jsonl / check.jsonl as spec/research manifests when sub-agents need context",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(" 3. Run: python3 task.py start <dir>", file=sys.stderr)
|
||||
else:
|
||||
print(" 2. Run: python3 task.py start <dir>", file=sys.stderr)
|
||||
print(" - Use /trellis:continue or phase context to decide the next step", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
|
||||
# Output relative path for script chaining
|
||||
@@ -363,6 +534,17 @@ def cmd_archive(args: argparse.Namespace) -> int:
|
||||
print(f" - {t.dir_name}/", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Refuse to archive anything that isn't a real task directly under
|
||||
# .trellis/tasks/. A mistyped name (e.g. "src") resolves to repo_root/src,
|
||||
# which is a dir but not a task — without this guard archive would move the
|
||||
# user's source directory out of the repo.
|
||||
if not is_within_tasks_dir(task_dir, repo_root):
|
||||
print(colored(
|
||||
f"Error: refusing to archive '{task_name}': "
|
||||
f"{task_dir} is not a task under {tasks_dir}",
|
||||
Colors.RED), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dir_name = task_dir.name
|
||||
task_json_path = task_dir / FILE_TASK_JSON
|
||||
|
||||
@@ -374,6 +556,19 @@ def cmd_archive(args: argparse.Namespace) -> int:
|
||||
if task_json_path.is_file():
|
||||
data = read_json(task_json_path)
|
||||
if data:
|
||||
# Warn (don't block) when the recorded branch is stale — it was
|
||||
# likely already merged and deleted (#399 item 2).
|
||||
stored_branch = data.get("branch")
|
||||
if stored_branch and not branch_exists_locally(stored_branch, repo_root):
|
||||
print(
|
||||
colored(
|
||||
f"Warning: recorded branch '{stored_branch}' no longer exists locally "
|
||||
"(likely merged and deleted).",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
data["status"] = "completed"
|
||||
data["completedAt"] = today
|
||||
write_json(task_json_path, data)
|
||||
@@ -410,7 +605,16 @@ def cmd_archive(args: argparse.Namespace) -> int:
|
||||
|
||||
# Auto-commit unless --no-commit
|
||||
if not getattr(args, "no_commit", False):
|
||||
_auto_commit_archive(dir_name, repo_root, modified_children)
|
||||
if not _auto_commit_archive(dir_name, repo_root, modified_children):
|
||||
print(
|
||||
colored(
|
||||
"Archive moved on disk, but git auto-commit did not complete. "
|
||||
"Resolve `git status` before continuing.",
|
||||
Colors.RED,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Return the archive path
|
||||
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
|
||||
@@ -427,7 +631,7 @@ def _auto_commit_archive(
|
||||
task_name: str,
|
||||
repo_root: Path,
|
||||
modified_children: list[str] | None = None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Stage Trellis-owned task paths and commit after archive.
|
||||
|
||||
Scoped narrowly to the archived task's source + destination paths
|
||||
@@ -449,14 +653,21 @@ def _auto_commit_archive(
|
||||
"[OK] session_auto_commit: false — skipping git stage/commit.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
return True
|
||||
|
||||
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
|
||||
rc, tracked_out, _ = run_git(
|
||||
["ls-files", "--", source_rel],
|
||||
cwd=repo_root,
|
||||
)
|
||||
source_was_tracked = rc == 0 and bool(tracked_out.strip())
|
||||
|
||||
paths = safe_archive_paths_to_add(
|
||||
repo_root, task_name=task_name, modified_children=modified_children
|
||||
)
|
||||
if not paths:
|
||||
print("[OK] No task changes to commit.", file=sys.stderr)
|
||||
return
|
||||
return True
|
||||
|
||||
success, _, err = safe_git_add(paths, repo_root)
|
||||
if not success:
|
||||
@@ -467,7 +678,7 @@ def _auto_commit_archive(
|
||||
f"[WARN] git add failed: {err.strip() if err else 'unknown error'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
return not source_was_tracked
|
||||
|
||||
# Belt-and-suspenders for the phantom-delete bug: `safe_git_add` uses
|
||||
# `git add` (no -A) which only stages additions/modifications. The
|
||||
@@ -478,7 +689,6 @@ def _auto_commit_archive(
|
||||
#
|
||||
# `--ignore-unmatch` makes this a no-op when the task was never tracked
|
||||
# (e.g. archiving a task that lived only in working tree).
|
||||
source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
|
||||
run_git(
|
||||
["rm", "-r", "--cached", "--ignore-unmatch", "--", source_rel],
|
||||
cwd=repo_root,
|
||||
@@ -490,14 +700,16 @@ def _auto_commit_archive(
|
||||
)
|
||||
if rc == 0:
|
||||
print("[OK] No task changes to commit.", file=sys.stderr)
|
||||
return
|
||||
return True
|
||||
|
||||
commit_msg = f"chore(task): archive {task_name}"
|
||||
rc, _, err = run_git(["commit", "-m", commit_msg], cwd=repo_root)
|
||||
if rc == 0:
|
||||
print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
|
||||
return True
|
||||
else:
|
||||
print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
|
||||
return not source_was_tracked
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -695,3 +907,39 @@ def cmd_set_scope(args: argparse.Namespace) -> int:
|
||||
|
||||
print(colored(f"✓ Scope set to: {scope}", Colors.GREEN))
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command: set-meta
|
||||
# =============================================================================
|
||||
|
||||
def cmd_set_meta(args: argparse.Namespace) -> int:
|
||||
"""Set/overwrite one metadata key on an existing task."""
|
||||
repo_root = get_repo_root()
|
||||
target_dir = resolve_task_dir(args.dir, repo_root)
|
||||
key = args.key
|
||||
value = args.value
|
||||
|
||||
if not key:
|
||||
print(colored("Error: Missing arguments", Colors.RED))
|
||||
print("Usage: python3 task.py set-meta <task-dir> <key> <value>")
|
||||
return 1
|
||||
|
||||
task_json = target_dir / FILE_TASK_JSON
|
||||
if not task_json.is_file():
|
||||
print(colored(f"Error: task.json not found at {target_dir}", Colors.RED))
|
||||
return 1
|
||||
|
||||
data = read_json(task_json)
|
||||
if not data:
|
||||
return 1
|
||||
|
||||
meta = data.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
meta[key] = value
|
||||
data["meta"] = meta
|
||||
write_json(task_json, data)
|
||||
|
||||
print(colored(f"✓ Meta set: {key} = {value}", Colors.GREEN))
|
||||
return 0
|
||||
|
||||
@@ -69,6 +69,30 @@ def is_safe_task_path(task_path: str, repo_root: Path | None = None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def is_within_tasks_dir(task_dir_abs: Path, repo_root: Path | None = None) -> bool:
|
||||
"""Check that a resolved task directory really is a task under the tasks dir.
|
||||
|
||||
A real task lives directly at ``.trellis/tasks/<name>``. This returns True
|
||||
only when ``task_dir_abs`` is an immediate child of the tasks directory.
|
||||
|
||||
Guards archive: ``resolve_task_dir`` falls back to ``repo_root/<name>`` for
|
||||
an unknown name, so a mistyped ``task.py archive src`` resolves to the real
|
||||
``src/`` source directory. Without this check archive would ``shutil.move``
|
||||
it out of the repo. Also rejects the tasks dir itself and anything nested
|
||||
under ``archive/`` (already-archived tasks).
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
try:
|
||||
resolved = task_dir_abs.resolve()
|
||||
tasks_resolved = get_tasks_dir(repo_root).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
if resolved.parent != tasks_resolved:
|
||||
return False
|
||||
return resolved.name != "archive"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task Lookup
|
||||
# =============================================================================
|
||||
|
||||
@@ -78,10 +78,11 @@ def _parse_yaml_block(
|
||||
key, _, value = stripped.partition(":")
|
||||
key = key.strip()
|
||||
value = _strip_inline_comment(value).strip()
|
||||
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
|
||||
value = _unquote(value)
|
||||
current_list = None
|
||||
|
||||
if value:
|
||||
if value or was_quoted:
|
||||
target[key] = value
|
||||
i += 1
|
||||
else:
|
||||
|
||||
@@ -60,15 +60,12 @@ def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
|
||||
|
||||
|
||||
def get_phase_index() -> str:
|
||||
"""Return Phase Index + Phase 1/2/3 step bodies from workflow.md.
|
||||
"""Return the compact Phase Index summary from workflow.md.
|
||||
|
||||
Matches what the SessionStart hook injects into the `<workflow>` block:
|
||||
starts at `## Phase Index`, continues through `## Phase 1: Plan`,
|
||||
`## Phase 2: Execute`, `## Phase 3: Finish`, stops at
|
||||
`## Customizing Trellis (for forks)` (the docs-for-forks footer).
|
||||
`[workflow-state:STATUS]` tag blocks (now embedded in Phase Index since
|
||||
v0.5.0-rc.0) are consumed by the UserPromptSubmit hook so they're
|
||||
stripped from this output.
|
||||
SessionStart and no-step phase context use this small summary as their
|
||||
orientation payload. Detailed Phase 1/2/3 instructions are loaded with
|
||||
``get_step`` on demand. ``[workflow-state:STATUS]`` tag blocks are
|
||||
consumed by the per-turn hook, so they're stripped from this output.
|
||||
"""
|
||||
text = _read_workflow()
|
||||
lines = text.splitlines()
|
||||
@@ -80,7 +77,7 @@ def get_phase_index() -> str:
|
||||
if start is None and stripped == _PHASE_INDEX_HEADING:
|
||||
start = i
|
||||
continue
|
||||
if start is not None and stripped == "## Customizing Trellis (for forks)":
|
||||
if start is not None and stripped == "## Phase 1: Plan":
|
||||
end = i
|
||||
break
|
||||
|
||||
@@ -147,27 +144,34 @@ def _platform_matches(platform: str, block_names: list[str]) -> bool:
|
||||
def resolve_effective_platform(platform: str, config: dict) -> str:
|
||||
"""Map ``codex`` to a dispatch-mode-namespaced virtual platform name.
|
||||
|
||||
When ``--platform codex`` is passed, return ``"codex-inline"`` (default)
|
||||
or ``"codex-sub-agent"`` based on ``.trellis/config.yaml`` ``codex.dispatch_mode``.
|
||||
When ``--platform codex`` is passed, return ``"codex-sub-agent"`` by
|
||||
default or ``"codex-inline"`` when explicitly configured in
|
||||
``.trellis/config.yaml``. ``sub-agent`` remains an alias for ``auto``.
|
||||
``filter_platform`` then surfaces blocks whose marker lists include the
|
||||
namespaced name (e.g. ``[codex-sub-agent, ...]`` or ``[codex-inline, Kilo,
|
||||
Antigravity, Windsurf]``).
|
||||
Antigravity, Devin]``).
|
||||
|
||||
Default is ``inline`` because Codex sub-agents run with ``fork_turns="none"``
|
||||
isolation and can't inherit the parent session's task context — inline
|
||||
keeps the main agent in charge so context isn't lost. Invalid / missing
|
||||
values also fall back to inline.
|
||||
Native Codex context injection supports the ``auto`` default. Invalid
|
||||
explicit values fall back to ``inline`` safely; this renderer deliberately
|
||||
does not warn because it can run in normal CLI output flows.
|
||||
|
||||
Other platforms are returned unchanged.
|
||||
"""
|
||||
if platform == "codex":
|
||||
mode = "inline"
|
||||
mode = "auto"
|
||||
codex_cfg = config.get("codex") if isinstance(config, dict) else None
|
||||
if isinstance(codex_cfg, dict):
|
||||
cfg_mode = codex_cfg.get("dispatch_mode")
|
||||
if cfg_mode in ("inline", "sub-agent"):
|
||||
mode = cfg_mode
|
||||
return f"codex-{mode}"
|
||||
if codex_cfg is not None:
|
||||
if not isinstance(codex_cfg, dict):
|
||||
mode = "inline"
|
||||
else:
|
||||
cfg_mode = str(codex_cfg.get("dispatch_mode", mode)).strip().lower()
|
||||
if cfg_mode == "inline":
|
||||
mode = "inline"
|
||||
elif cfg_mode in ("auto", "sub-agent"):
|
||||
mode = "auto"
|
||||
else:
|
||||
mode = "inline"
|
||||
return "codex-sub-agent" if mode == "auto" else "codex-inline"
|
||||
return platform
|
||||
|
||||
|
||||
|
||||
+117
-15
@@ -4,16 +4,17 @@
|
||||
Task Management Script.
|
||||
|
||||
Usage:
|
||||
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>]
|
||||
python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start]
|
||||
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 current [--source] # Show active task
|
||||
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 list # List active tasks
|
||||
python3 task.py list-archive [month] # List archived tasks
|
||||
@@ -24,6 +25,7 @@ Usage:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from common.log import Colors, colored
|
||||
@@ -53,6 +55,7 @@ from common.task_store import (
|
||||
cmd_set_branch,
|
||||
cmd_set_base_branch,
|
||||
cmd_set_scope,
|
||||
cmd_set_meta,
|
||||
cmd_add_subtask,
|
||||
cmd_remove_subtask,
|
||||
)
|
||||
@@ -166,6 +169,27 @@ def cmd_current(args: argparse.Namespace) -> int:
|
||||
repo_root = get_repo_root()
|
||||
active = resolve_active_task(repo_root)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
task_obj = None
|
||||
if active.task_path:
|
||||
data = read_json(repo_root / active.task_path / FILE_TASK_JSON) or {}
|
||||
task_obj = {
|
||||
"dir": active.task_path,
|
||||
"id": data.get("id") or data.get("name"),
|
||||
"title": data.get("title"),
|
||||
"status": data.get("status"),
|
||||
"parent": data.get("parent"),
|
||||
"children": data.get("children", []),
|
||||
"branch": data.get("branch"),
|
||||
"base_branch": data.get("base_branch"),
|
||||
}
|
||||
print(json.dumps({
|
||||
"current_task": task_obj,
|
||||
"source": active.source,
|
||||
"stale": active.stale,
|
||||
}, ensure_ascii=False))
|
||||
return 0 if active.task_path else 1
|
||||
|
||||
if args.source:
|
||||
print(f"Current task: {active.task_path or '(none)'}")
|
||||
print(f"Source: {active.source}")
|
||||
@@ -184,6 +208,24 @@ def cmd_current(args: argparse.Namespace) -> int:
|
||||
# Command: list
|
||||
# =============================================================================
|
||||
|
||||
def _display_status(t, all_statuses: dict) -> str:
|
||||
"""Return the status label to show for a task in `list` output.
|
||||
|
||||
A parent task's stored status stays "planning" until someone runs
|
||||
`task.py start` on the parent directly, even while its children are
|
||||
actively being worked — a misleading label for anyone scanning the
|
||||
list (#399 item 3). Show "active" instead when at least one child is
|
||||
past planning; the stored status.json value is left untouched.
|
||||
"""
|
||||
if t.status == "planning" and t.children:
|
||||
child_in_flight = any(
|
||||
all_statuses.get(c) not in (None, "planning") for c in t.children
|
||||
)
|
||||
if child_in_flight:
|
||||
return "active"
|
||||
return t.status
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
"""List active tasks."""
|
||||
repo_root = get_repo_root()
|
||||
@@ -192,6 +234,38 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
developer = get_developer(repo_root)
|
||||
filter_mine = args.mine
|
||||
filter_status = args.status
|
||||
as_json = getattr(args, "json", False)
|
||||
|
||||
# Single pass: collect all tasks via shared iterator
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
if as_json:
|
||||
if filter_mine and not developer:
|
||||
print(json.dumps({"error": "No developer set"}), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
items = []
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
t = all_tasks[dir_name]
|
||||
if filter_mine and (t.assignee or "-") != developer:
|
||||
continue
|
||||
if filter_status and t.status != filter_status:
|
||||
continue
|
||||
items.append({
|
||||
"dir": f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}",
|
||||
"id": t.raw.get("id") or dir_name,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"display_status": _display_status(t, all_statuses),
|
||||
"priority": t.priority,
|
||||
"assignee": t.assignee or None,
|
||||
"parent": t.parent,
|
||||
"children": list(t.children),
|
||||
"package": t.package,
|
||||
})
|
||||
print(json.dumps({"tasks": items}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
if filter_mine:
|
||||
if not developer:
|
||||
@@ -202,10 +276,6 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
print(colored("All active tasks:", Colors.BLUE))
|
||||
print()
|
||||
|
||||
# Single pass: collect all tasks via shared iterator
|
||||
all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
|
||||
all_statuses = {name: t.status for name, t in all_tasks.items()}
|
||||
|
||||
# Display tasks hierarchically
|
||||
count = 0
|
||||
|
||||
@@ -228,6 +298,7 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
|
||||
# Children progress
|
||||
progress = children_progress(t.children, all_statuses)
|
||||
status_label = _display_status(t, all_statuses)
|
||||
|
||||
# Package tag
|
||||
pkg_tag = f" @{t.package}" if t.package else ""
|
||||
@@ -235,9 +306,9 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
prefix = " " * indent + " - "
|
||||
|
||||
if filter_mine:
|
||||
print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress}{marker}")
|
||||
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress}{marker}")
|
||||
else:
|
||||
print(f"{prefix}{dir_name}/ ({t.status}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
|
||||
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
|
||||
count += 1
|
||||
|
||||
# Print children indented
|
||||
@@ -245,9 +316,12 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
if child_name in all_tasks:
|
||||
_print_task(child_name, indent + 1)
|
||||
|
||||
# Display only top-level tasks (those without a parent)
|
||||
# Display only top-level tasks: those without a parent, plus orphans
|
||||
# whose recorded parent is not (or no longer) in the active set — a
|
||||
# dangling parent ref must still render flat instead of disappearing.
|
||||
for dir_name in sorted(all_tasks.keys()):
|
||||
if not all_tasks[dir_name].parent:
|
||||
parent = all_tasks[dir_name].parent
|
||||
if not parent or parent not in all_tasks:
|
||||
_print_task(dir_name)
|
||||
|
||||
if count == 0:
|
||||
@@ -307,6 +381,7 @@ 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 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
|
||||
@@ -316,10 +391,11 @@ Usage:
|
||||
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 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
|
||||
python3 task.py list [--mine] [--status <status>] List tasks
|
||||
python3 task.py list [--mine] [--status <status>] [--json] List tasks
|
||||
python3 task.py list-archive [YYYY-MM] List archived tasks
|
||||
|
||||
Monorepo options:
|
||||
@@ -328,10 +404,12 @@ Monorepo options:
|
||||
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 add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
|
||||
python3 task.py set-branch <dir> task/add-login
|
||||
@@ -369,12 +447,12 @@ def main() -> int:
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"sub-agent-capable platforms and curated by the AI during Phase 1.3.",
|
||||
"sub-agent-capable platforms and curated by the AI during planning when needed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("See .trellis/workflow.md Phase 1.3 or run:", file=sys.stderr)
|
||||
print("See .trellis/workflow.md planning artifact guidance or run:", file=sys.stderr)
|
||||
print(
|
||||
" python3 ./.trellis/scripts/get_context.py --mode phase --step 1.3",
|
||||
" python3 ./.trellis/scripts/get_context.py --mode phase --step 1",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
@@ -392,12 +470,26 @@ 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("--slug", "-s", help="Task slug")
|
||||
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("--parent", help="Parent task directory (establishes subtask link)")
|
||||
p_create.add_argument("--package", help="Package name for monorepo projects")
|
||||
p_create.add_argument(
|
||||
"--base-branch",
|
||||
help="PR target branch (overrides origin/HEAD detection and the checked-out-branch fallback)",
|
||||
)
|
||||
p_create.add_argument(
|
||||
"--meta",
|
||||
action="append",
|
||||
help="Task metadata key=value (repeatable)",
|
||||
)
|
||||
p_create.add_argument(
|
||||
"--no-start",
|
||||
action="store_true",
|
||||
help="Create the task without making it active in this session",
|
||||
)
|
||||
|
||||
# add-context
|
||||
p_add = subparsers.add_parser("add-context", help="Add context entry")
|
||||
@@ -422,6 +514,8 @@ def main() -> int:
|
||||
p_current = subparsers.add_parser("current", help="Show active task")
|
||||
p_current.add_argument("--source", action="store_true",
|
||||
help="Show active task source")
|
||||
p_current.add_argument("--json", action="store_true",
|
||||
help="Output machine-readable JSON")
|
||||
|
||||
# finish
|
||||
subparsers.add_parser("finish", help="Clear active task")
|
||||
@@ -441,6 +535,12 @@ def main() -> int:
|
||||
p_scope.add_argument("dir", help="Task directory")
|
||||
p_scope.add_argument("scope", help="Scope name")
|
||||
|
||||
# set-meta
|
||||
p_setmeta = subparsers.add_parser("set-meta", help="Set/overwrite a task metadata key")
|
||||
p_setmeta.add_argument("dir", help="Task directory")
|
||||
p_setmeta.add_argument("key", help="Metadata key")
|
||||
p_setmeta.add_argument("value", help="Metadata value")
|
||||
|
||||
# archive
|
||||
p_archive = subparsers.add_parser("archive", help="Archive task")
|
||||
p_archive.add_argument("name", help="Task directory or name")
|
||||
@@ -450,6 +550,7 @@ def main() -> int:
|
||||
p_list = subparsers.add_parser("list", help="List tasks")
|
||||
p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
|
||||
p_list.add_argument("--status", "-s", help="Filter by status")
|
||||
p_list.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
||||
|
||||
# add-subtask
|
||||
p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
|
||||
@@ -482,6 +583,7 @@ def main() -> int:
|
||||
"set-branch": cmd_set_branch,
|
||||
"set-base-branch": cmd_set_base_branch,
|
||||
"set-scope": cmd_set_scope,
|
||||
"set-meta": cmd_set_meta,
|
||||
"archive": cmd_archive,
|
||||
"add-subtask": cmd_add_subtask,
|
||||
"remove-subtask": cmd_remove_subtask,
|
||||
|
||||
在新工单中引用
屏蔽一个用户