feat: publish Dever core with project scaffolding and versioned AI skills

This commit is contained in:
Antigravity committed 2026-10-04 13:37:30 +00:00
1 parent 70fa73cdc2
commit ac458ffa7f
960 files changed
+221319 -1163

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
"""
Trellis Python Scripts
This module provides Python implementations of Trellis workflow scripts.
"""
+1545
View File
File diff suppressed because it is too large. Load diff
+94
View File
@@ -0,0 +1,94 @@
"""
Common utilities for Trellis workflow scripts.
This module provides shared functionality used by other Trellis scripts.
"""
import io
import sys
# =============================================================================
# Windows Encoding Fix (MUST be at top, before any other output)
# =============================================================================
# On Windows, stdout defaults to the system code page (often GBK/CP936).
# This causes UnicodeEncodeError when printing non-ASCII characters.
#
# Any script that imports from common will automatically get this fix.
# =============================================================================
def _configure_stream(stream: object) -> object:
"""Configure a stream for UTF-8 encoding on Windows."""
# Try reconfigure() first (Python 3.7+, more reliable)
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
return stream
# Fallback: detach and rewrap with TextIOWrapper
elif hasattr(stream, "detach"):
return io.TextIOWrapper(
stream.detach(), # type: ignore[union-attr]
encoding="utf-8",
errors="replace",
)
return stream
if sys.platform == "win32":
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
def configure_encoding() -> None:
"""
Configure stdout/stderr/stdin for UTF-8 encoding on Windows.
This is automatically called when importing from common,
but can be called manually for scripts that don't import common.
Safe to call multiple times.
"""
global sys
if sys.platform == "win32":
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
from .paths import (
DIR_WORKFLOW,
DIR_WORKSPACE,
DIR_TASKS,
DIR_ARCHIVE,
DIR_SPEC,
DIR_SCRIPTS,
FILE_DEVELOPER,
FILE_CURRENT_TASK,
FILE_TASK_JSON,
FILE_JOURNAL_PREFIX,
ENV_DEVELOPER,
DEVELOPER_HINT,
get_repo_root,
get_developer,
check_developer,
get_tasks_dir,
get_workspace_dir,
get_active_journal_file,
count_lines,
get_current_task,
get_current_task_abs,
normalize_task_ref,
resolve_task_ref,
set_current_task,
clear_current_task,
has_current_task,
generate_task_date_prefix,
)
from .active_task import (
ActiveTask,
clear_active_task,
resolve_active_task,
resolve_context_key,
set_active_task,
)
+857
View File
@@ -0,0 +1,857 @@
#!/usr/bin/env python3
"""Session-scoped active task resolution.
The user-facing concept is a single "active task". Trellis stores that pointer
per AI session/window under `.trellis/.runtime/sessions/`; without a stable
session key there is no active task.
"""
from __future__ import annotations
import hashlib
import os
import re
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .io import read_json as _io_read_json, write_json as _io_write_json
DIR_WORKFLOW = ".trellis"
DIR_TASKS = "tasks"
DIR_RUNTIME = ".runtime"
DIR_SESSIONS = "sessions"
DIR_SHELL_TICKETS = "shell-tickets"
# Pre-0.6.13 name, when the bridge was Cursor-only. Still read so a session that
# was mid-command across an upgrade does not silently degrade; never written.
# Tickets are 30-second ephemera, so the old directory ages out by itself —
# there is nothing to migrate, only a glob on a directory that is normally
# absent. The alternative (ignore it) would land its one lost command on the
# platform that works today.
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
SHELL_TICKET_TTL_SECONDS = 30
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
_TRANSCRIPT_KEYS = ("transcript_path", "transcriptPath", "transcript")
_NESTED_KEYS = ("input", "properties", "event", "hook_input", "hookInput")
_KNOWN_PLATFORMS = {
"claude",
"codex",
"cursor",
"opencode",
"gemini",
"droid",
"qoder",
"codebuddy",
"kiro",
"copilot",
"pi",
"trae",
"grok",
"kimi",
"zcode",
"snow",
"dsh",
}
# Every name below records how it was checked. Do NOT add a name by analogy
# with a neighbour: a 2026-08-05 audit of all 21 platforms found 12 of the 21
# declared names had never existed anywhere — they were pattern-guessed from a
# `<PLATFORM>_SESSION_ID` shape no vendor agreed to, and the uniformity was the
# only "evidence" behind them. A platform with no verified name belongs in no
# table; it resolves through TRELLIS_CONTEXT_ID or its hook/plugin bridge.
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
# REAL (reported 2026-08-13 against DSH 0.1.0-rc.6 by @SajoLuo, from a live
# run: DSH exports DSH_SESSION_ID plus DSH_SHELL=1 into its managed shell).
# MUST STAY FIRST. A DSH session can inherit an outer host's identity — a
# DSH launched from Codex still carries CODEX_THREAD_ID — and the untargeted
# lookup below walks this table in order, so any earlier entry would claim
# the session and write a foreign `codex_<thread>` pointer for DSH work.
# DSH_SESSION_ID is the only name here no other vendor sets, so first place
# is safe: it cannot mis-claim a non-DSH session.
("dsh", ("DSH_SESSION_ID",)),
# REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
# child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
# was removed here — verified absent from that same live environment.
("claude", ("CLAUDE_CODE_SESSION_ID",)),
# REAL, undocumented (verified 2026-08-05: injected by codex-cli 0.146.0
# into shell children, absent from the parent env; openai/codex#19937).
# CODEX_SESSION_ID was removed — absent from a live `codex exec` env.
("codex", ("CODEX_THREAD_ID",)),
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): set by Gemini's
# hookRunner.ts. Its shell tool builds the child env in
# shellExecutionService.ts and adds only GEMINI_CLI/TERM/PAGER/GIT_PAGER, so
# this never reaches a bash child — it resolves only inside a hook process.
("gemini", ("GEMINI_SESSION_ID",)),
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): docs.qoder.com/zh/
# extensions/hooks documents it as injected during hook execution by the
# Qoder *IDE plugin*. Absent from the Qoder CLI hook docs and from Lingma.
("qoder", ("QODER_SESSION_ID",)),
# UNVERIFIED (2026-08-05): absent from kiro.dev/docs/hooks/, but Dynatrace
# dtctl, oh-my-agent and gastown all key agent detection on it and one notes
# it is "set in both interactive and --no-interactive". Kept because that is
# absence of evidence, not evidence of absence. To settle: run
# `env | grep KIRO` from a Kiro shell-tool call on a machine with Kiro.
("kiro", ("KIRO_SESSION_ID",)),
# UNVERIFIED (2026-08-05): absent from docs.github.com/en/copilot/reference/
# hooks-reference and from the CLI programmatic reference. To settle: run
# `copilot help environment` (the authoritative list per those docs) — not
# runnable here, the CLI is not installed and copilot-cli ships no source.
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
# REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
# installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
# / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
# declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
# name ZCode would actually reuse is CLAUDE_CODE_SESSION_ID. Try that first,
# keep the historical name as a fallback: if neither exists nothing changes.
# Platform-scoped lookup (_iter_env_keys filters by platform name), so the
# entry only fires once the resolver detected "zcode" — no collision with
# the claude entry above.
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
# REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
# exports SNOW_SESSION_ID into hook/terminal/sub-agent children and names
# Trellis in its source header. TRELLIS_CONTEXT_ID stays the preferred
# override — Snow sets that too.
("snow", ("SNOW_SESSION_ID",)),
)
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
# REAL in cursor-agent (CLI), undocumented (verified 2026-08-05: the value
# matches ~/.cursor/chats/<ws>/<id>). The Cursor *IDE* is unverified — a
# 2026-05 forum request for it drew no staff reply. The invented
# CURSOR_SESSION_ID was removed from the session table: empty in a live
# cursor-agent shell. Cursor's other path is the shell ticket below
# (_lookup_shell_ticket_context_key), which is not Cursor-specific.
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
)
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
# scripts; empty in the agent's own shell env.
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
# UNVERIFIED — never researched. The 2026-08-05 audit covered the session
# table only, so do not infer these are real *or* fake from that work
# (CLAUDE_/CODEX_TRANSCRIPT_PATH were removed because those two *were*
# checked: absent from docs and from live envs). To settle each: run
# `env | grep _TRANSCRIPT_PATH` inside a hook and inside a shell-tool call.
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
("qoder", ("QODER_TRANSCRIPT_PATH",)),
("codebuddy", ("CODEBUDDY_TRANSCRIPT_PATH",)),
)
_ENV_PLATFORM_ALIASES = {
"claude-code": "claude",
"factory": "droid",
"factory-ai": "droid",
"github-copilot": "copilot",
}
# ZCode intentionally reuses Claude's session env var name. 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",
# Factory Droid's config directory is `.factory/`, so a hook that names its
# platform after the directory it was installed in reports "factory". Its
# sibling hooks report "droid". One runtime filename either way.
"factory": "droid",
}
@dataclass(frozen=True)
class ActiveTask:
"""Resolved active task state."""
task_path: str | None
source_type: str
context_key: str | None = None
stale: bool = False
@property
def source(self) -> str:
"""Human-readable source label."""
if self.source_type == "session" and self.context_key:
return f"session:{self.context_key}"
if self.source_type == "session-fallback" and self.context_key:
return f"session-fallback:{self.context_key}"
return self.source_type
def normalize_task_ref(task_ref: str) -> str:
"""Normalize a task ref for stable storage and comparison."""
normalized = task_ref.strip()
if not normalized:
return ""
path_obj = Path(normalized)
if path_obj.is_absolute():
return str(path_obj)
normalized = normalized.replace("\\", "/")
while normalized.startswith("./"):
normalized = normalized[2:]
if normalized.startswith(f"{DIR_TASKS}/"):
return f"{DIR_WORKFLOW}/{normalized}"
return normalized
def resolve_task_ref(task_ref: str, repo_root: Path) -> Path | None:
"""Resolve a task ref to an absolute task directory inside the repo.
Mirrors `paths.resolve_task_ref` (same containment check). Duplicated
rather than imported because this module is loaded standalone — hooks add
it to `sys.path` directly — so it stays zero-relative-import on purpose.
"""
normalized = normalize_task_ref(task_ref)
if not normalized:
return None
try:
root = repo_root.resolve()
except OSError:
return None
path_obj = Path(normalized)
if path_obj.is_absolute():
candidate = path_obj
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
candidate = root / path_obj
else:
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
# Both sides are resolved because repo_root itself may sit behind a
# symlink (/tmp on macOS does), and resolve() is what collapses `..`
# instead of leaving it for a lexical relative_to() to wave through.
try:
resolved = candidate.resolve()
workflow_real = (root / DIR_WORKFLOW).resolve()
except OSError:
return None
try:
resolved.relative_to(root)
return resolved
except ValueError:
pass
# `.trellis` may itself be a symlink into a store outside the repo (#567).
# The workflow dir's own real location is then a second legitimate
# containment base; a ref that escapes BOTH bases is still refused. Map
# back to the in-repo (lexical) form so callers store a repo-relative ref.
try:
rel = resolved.relative_to(workflow_real)
except ValueError:
return None
return root / DIR_WORKFLOW / rel
def _runtime_sessions_dir(repo_root: Path) -> Path:
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
def _sanitize_key(raw: str) -> str:
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw.strip())
safe = safe.strip("._-")
return safe[:160] if safe else ""
def _hash_value(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
def _as_dict(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
def _string_value(value: Any) -> str | None:
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return None
def _lookup_string(data: dict[str, Any], keys: tuple[str, ...]) -> str | None:
for key in keys:
value = _string_value(data.get(key))
if value:
return value
for nested_key in _NESTED_KEYS:
nested = _as_dict(data.get(nested_key))
if not nested:
continue
value = _lookup_string(nested, keys)
if value:
return value
return None
def _detect_platform(platform_input: dict[str, Any] | None, platform: str | None) -> str:
if platform:
return _sanitize_key(platform) or "session"
if platform_input:
for key in ("_trellis_platform", "trellis_platform", "platform", "source"):
value = _string_value(platform_input.get(key))
if value:
return _sanitize_key(value) or "session"
if _string_value(platform_input.get("cursor_version")):
return "cursor"
return "session"
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)
if safe_value:
return f"{platform_name}_{safe_value}"
return f"{platform_name}_{_hash_value(value)}"
def _iter_env_keys(
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
platform_name: str | None,
) -> tuple[tuple[str, tuple[str, ...]], ...]:
"""Narrow an env-key table to one platform, or return all of it.
A platform with no entry yields an empty tuple, and the caller's `for` loop
simply does not run. That is the normal case, not an error: platforms with
no verified env var name are deliberately absent from these tables.
"""
if not platform_name:
return env_keys
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
return matched
def _env_platform_name(platform_name: str | None) -> str | None:
if not platform_name or platform_name == "session":
return None
return _ENV_PLATFORM_ALIASES.get(platform_name, platform_name)
def _lookup_env_context_key(platform_name: str | None) -> str | None:
"""Resolve a context key from platform-provided environment variables.
Hooks pass `TRELLIS_CONTEXT_ID` to subprocesses they launch, but an AI-run
shell command can only see session identity if the host platform exports it
in the command environment. These names are best-effort adapters; if none
are present, there is no session-scoped active task.
"""
env_platform_name = _env_platform_name(platform_name)
for name, keys in _iter_env_keys(_ENV_SESSION_KEYS, env_platform_name):
for key in keys:
value = _string_value(os.environ.get(key))
if value:
return _context_key(name, "session", value)
for name, keys in _iter_env_keys(_ENV_CONVERSATION_KEYS, env_platform_name):
for key in keys:
value = _string_value(os.environ.get(key))
if value:
return _context_key(name, "conversation", value)
for name, keys in _iter_env_keys(_ENV_TRANSCRIPT_KEYS, env_platform_name):
for key in keys:
value = _string_value(os.environ.get(key))
if value:
return _context_key(name, "transcript", value)
return None
def _find_repo_root_from_cwd() -> Path | None:
current = Path.cwd().resolve()
while True:
if (current / DIR_WORKFLOW).is_dir():
return current
if current == current.parent:
return None
current = current.parent
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME
return (
runtime_dir / DIR_SHELL_TICKETS,
runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS,
)
def _remove_file(path: Path) -> bool:
try:
path.unlink()
return True
except OSError:
return False
def _task_refs_match(left: str | None, right: str | None, repo_root: Path) -> bool:
if not left or not right:
return False
left_path = resolve_task_ref(left, repo_root)
right_path = resolve_task_ref(right, repo_root)
if left_path is not None and right_path is not None:
return left_path == right_path
return normalize_task_ref(left) == normalize_task_ref(right)
def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> bool:
if Path(sys.argv[0]).name != "task.py":
return False
args = tuple(sys.argv[1:])
if not args:
return False
command_name = args[0]
if command_name not in TASK_SESSION_COMMANDS:
return False
subcommands = ticket.get("subcommands")
if not isinstance(subcommands, list):
return False
for subcommand in subcommands:
if not isinstance(subcommand, dict):
continue
if _string_value(subcommand.get("name")) != command_name:
continue
if command_name != "start":
return True
task_ref = args[1] if len(args) > 1 else None
if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
return True
return False
def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> bool:
expires_at = ticket.get("expires_at_epoch")
if isinstance(expires_at, (int, float)) and expires_at < now:
_remove_file(ticket_path)
return False
created_at = ticket.get("created_at_epoch")
if isinstance(created_at, (int, float)):
if now - created_at <= SHELL_TICKET_TTL_SECONDS:
return True
_remove_file(ticket_path)
return False
return True
def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
cwd = _string_value(ticket.get("cwd"))
if not cwd:
return True
try:
Path(cwd).resolve().relative_to(repo_root)
except ValueError:
return False
return True
def _matching_ticket_context_key(
ticket_path: Path,
repo_root: Path,
now: float,
) -> str | None:
"""Accept a ticket on its merits, never on which platform wrote it.
The `platform` field a ticket carries is debugging metadata; gating on it
was what kept this bridge invisible to every platform but Cursor.
"""
ticket = _read_json(ticket_path)
if ticket is None:
return None
if not _ticket_is_fresh(ticket, ticket_path, now):
return None
if not _ticket_cwd_matches_repo(ticket, repo_root):
return None
if not _pending_ticket_matches_args(ticket, repo_root):
return None
return _string_value(ticket.get("context_key"))
def _lookup_shell_ticket_context_key() -> str | None:
"""Resolve session identity from a short-lived shell ticket.
No researched platform exports its session id into a shell child, but every
hook-capable one hands that id to a hook. So the hook that runs just before
a shell command writes a ticket, and this reads it back. A ticket counts
only when it is fresh, was written for this repo, and matches the `task.py`
subcommand now running — and only when exactly one fresh context key
matches. Two concurrent windows therefore both degrade rather than one
inheriting the other's pointer.
"""
repo_root = _find_repo_root_from_cwd()
if repo_root is None:
return None
now = time.time()
candidates: set[str] = set()
for ticket_dir in _shell_ticket_dirs(repo_root):
if not ticket_dir.is_dir():
continue
for ticket_path in ticket_dir.glob("*.json"):
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
if context_key:
candidates.add(context_key)
if len(candidates) == 1:
return next(iter(candidates))
return 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.
"""
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
if data:
session_id = _lookup_string(data, _SESSION_KEYS)
if session_id:
return _context_key(platform_name or "session", "session", session_id)
conversation_id = _lookup_string(data, _CONVERSATION_KEYS)
if conversation_id:
return _context_key(platform_name or "session", "conversation", conversation_id)
transcript_path = _lookup_string(data, _TRANSCRIPT_KEYS)
if transcript_path:
return _context_key(platform_name or "session", "transcript", transcript_path)
if allow_environment_context:
env_context_key = _lookup_env_context_key(platform_name)
if env_context_key:
return env_context_key
# Last in the chain on purpose: a platform that genuinely exports identity
# into the shell outranks a ticket, and no platform name gates the lookup.
if allow_environment_context:
return _lookup_shell_ticket_context_key()
return None
def _read_json(path: Path) -> dict[str, Any] | None:
"""Tolerant read of a session runtime file, non-objects included."""
data = _io_read_json(path)
return data if isinstance(data, dict) else None
def _write_json(path: Path, data: dict[str, Any]) -> bool:
"""Write a session runtime file atomically, creating the runtime dir.
Routes through io.write_json so session pointers get the same
temp-file-then-rename treatment as task.json (#429). A plain write_text
truncates the target first, so a crash mid-write would leave a session
file that reads back as no active task.
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
except OSError:
return False
return _io_write_json(path, data)
def _canonical_task_ref(task_path: str, repo_root: Path) -> str | None:
normalized = normalize_task_ref(task_path)
if not normalized:
return None
full_path = resolve_task_ref(normalized, repo_root)
if full_path is None or not full_path.is_dir():
return None
try:
return full_path.relative_to(repo_root.resolve()).as_posix()
except ValueError:
# resolve_task_ref already refused everything outside the repo, so this
# is unreachable. Refuse rather than fall back to an absolute path —
# that fallback is how an out-of-repo ref used to reach the session
# pointer and get replayed on every later turn.
return None
def _relative_task_ref(task_path: str, repo_root: Path) -> str:
"""Repo-relative posix ref for a task path that need not exist.
`_canonical_task_ref` resolves through the filesystem and so refuses a task
directory that has been moved away. Rename needs to name both sides of the
move, one of which is always absent.
"""
normalized = normalize_task_ref(task_path)
if not normalized:
return ""
candidate = Path(normalized)
if not candidate.is_absolute():
return normalized
try:
resolved = candidate.resolve()
root = repo_root.resolve()
workflow_real = (root / DIR_WORKFLOW).resolve()
except OSError:
return ""
try:
return resolved.relative_to(root).as_posix()
except ValueError:
pass
# Same dual-base containment as resolve_task_ref: a path through a
# symlinked `.trellis` (#567) maps back to its in-repo form; anything
# outside both bases is refused rather than stored as an absolute pointer.
try:
rel = resolved.relative_to(workflow_real)
except ValueError:
return ""
return (Path(DIR_WORKFLOW) / rel).as_posix()
def _active_from_ref(
task_ref: str | None,
repo_root: Path,
source_type: str,
context_key: str | None = None,
) -> ActiveTask | None:
if not task_ref:
return None
resolved = resolve_task_ref(task_ref, repo_root)
stale = resolved is None or not resolved.is_dir()
return ActiveTask(task_ref, source_type, context_key, stale)
def _context_path(repo_root: Path, context_key: str) -> Path:
return _runtime_sessions_dir(repo_root) / f"{context_key}.json"
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 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,
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"))
active = _active_from_ref(task_ref, repo_root, "session", context_key)
if active:
return active
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)
def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
"""Return the task pointed at by the sole session file, if exactly one exists.
Used when context-key resolution fails (typical for class-2 platform
sub-agents). Returns None if 0 or ≥2 session files are present — refuses
to pick across windows so 04-21's multi-session isolation contract holds.
"""
sessions_dir = _runtime_sessions_dir(repo_root)
if not sessions_dir.is_dir():
return None
session_files = sorted(sessions_dir.glob("*.json"))
if len(session_files) != 1:
return None
session_file = session_files[0]
context = _read_json(session_file) or {}
task_ref = _string_value(context.get("current_task"))
if not task_ref:
return None
fallback_key = session_file.stem
return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key)
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _context_metadata(
platform_input: dict[str, Any] | None,
platform: str | None,
context_key: str | None = None,
) -> dict[str, Any]:
data = _as_dict(platform_input) or {}
platform_name = _detect_platform(data, platform)
if platform_name == "session" and context_key:
prefix = context_key.split("_", 1)[0]
if prefix in _KNOWN_PLATFORMS:
platform_name = prefix
metadata: dict[str, Any] = {
"platform": platform_name,
"last_seen_at": _utc_now(),
}
for key in (*_SESSION_KEYS, *_CONVERSATION_KEYS, *_TRANSCRIPT_KEYS):
value = _lookup_string(data, (key,))
if value:
metadata[key] = value
return metadata
def set_active_task(
task_path: str,
repo_root: Path,
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
) -> ActiveTask | None:
"""Set the active task in session scope.
Returns None when no context key is available; callers should surface a
user-facing error that explains how to provide session identity.
"""
canonical = _canonical_task_ref(task_path, repo_root)
if canonical is None:
return None
context_key = resolve_context_key(platform_input, platform)
if not context_key:
return None
context_path = _context_path(repo_root, context_key)
context = _read_json(context_path) or {}
context.update(_context_metadata(platform_input, platform, context_key))
context["current_task"] = canonical
context.setdefault("current_run", None)
if not _write_json(context_path, context):
return None
return ActiveTask(canonical, "session", context_key)
def clear_active_task(
repo_root: Path,
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
) -> ActiveTask:
"""Clear the active task by deleting its resolved session context file."""
context_key = resolve_context_key(platform_input, platform)
if not context_key:
return ActiveTask(None, "none")
previous = resolve_active_task(repo_root, platform_input, platform)
if not previous.task_path or not previous.context_key:
return previous
context_path = _context_path(repo_root, previous.context_key)
if context_path.is_file():
_remove_file(context_path)
return previous
def clear_task_from_sessions(task_path: str, repo_root: Path) -> int:
"""Delete all session runtime files that point at a task."""
target = _canonical_task_ref(task_path, repo_root) or normalize_task_ref(task_path)
if not target:
return 0
cleared = 0
sessions_dir = _runtime_sessions_dir(repo_root)
if not sessions_dir.is_dir():
return cleared
for session_path in sessions_dir.glob("*.json"):
context = _read_json(session_path) or {}
current = _string_value(context.get("current_task"))
if not current:
continue
current_ref = _canonical_task_ref(current, repo_root) or normalize_task_ref(current)
if current_ref != target:
continue
if session_path.is_file() and _remove_file(session_path):
cleared += 1
return cleared
def repoint_task_in_sessions(old_path: str, new_path: str, repo_root: Path) -> int:
"""Move every session pointer from `old_path` to `new_path`.
Rename is the one lifecycle step where the task survives under a different
name, so clearing the pointers (what archive does) would be wrong: the user
would silently lose their active task and have to run `task.py start`
again to get context injection back. Repointing keeps the session valid
across the rename.
"""
# Not `_canonical_task_ref`: the caller repoints *after* moving the
# directory, so `old_path` no longer exists and canonicalization — which
# requires an existing directory — would return None for exactly the ref we
# need to match.
target = _relative_task_ref(old_path, repo_root)
replacement = _relative_task_ref(new_path, repo_root)
if not target or not replacement:
return 0
moved = 0
sessions_dir = _runtime_sessions_dir(repo_root)
if not sessions_dir.is_dir():
return moved
for session_path in sorted(sessions_dir.glob("*.json")):
context = _read_json(session_path)
if not context:
continue
current = _string_value(context.get("current_task"))
if not current:
continue
current_ref = _canonical_task_ref(current, repo_root) or _relative_task_ref(
current, repo_root
)
if current_ref != target:
continue
context["current_task"] = replacement
if _write_json(session_path, context):
moved += 1
return moved
def get_current_task_source(
repo_root: Path,
platform_input: dict[str, Any] | None = None,
platform: str | None = None,
) -> tuple[str, str | None, str | None]:
"""Return (`source_type`, `context_key`, `task_path`) for compatibility."""
active = resolve_active_task(repo_root, platform_input, platform)
return active.source_type, active.context_key, active.task_path
+950
View File
@@ -0,0 +1,950 @@
"""
CLI Adapter for Multi-Platform Support.
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)
- opencode: OpenCode
- cursor: Cursor IDE
- iflow: iFlow CLI
- codex: Codex CLI (skills-based)
- kilo: Kilo CLI
- kiro: Kiro Code (skills-based)
- gemini: Gemini CLI
- antigravity: Antigravity (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
adapter = CLIAdapter("opencode")
cmd = adapter.build_run_command(
agent="dispatch",
session_id="abc123",
prompt="Start the pipeline"
)
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, Literal
Platform = Literal[
"claude",
"opencode",
"cursor",
"iflow",
"codex",
"kilo",
"kiro",
"gemini",
"antigravity",
"devin",
"qoder",
"codebuddy",
"copilot",
"droid",
"pi",
"trae",
"omp",
"grok",
"kimi",
]
@dataclass
class CLIAdapter:
"""Adapter for different AI coding CLI tools."""
platform: Platform
# =========================================================================
# Agent Name Mapping
# =========================================================================
# OpenCode has built-in agents that cannot be overridden
# See: https://github.com/sst/opencode/issues/4271
# Note: Class-level constant, not a dataclass field
_AGENT_NAME_MAP: ClassVar[dict[Platform, dict[str, str]]] = {
"claude": {}, # No mapping needed
"opencode": {
"plan": "trellis-plan", # 'plan' is built-in in OpenCode
},
}
def get_agent_name(self, agent: str) -> str:
"""Get platform-specific agent name.
Args:
agent: Original agent name (e.g., 'plan', 'dispatch')
Returns:
Platform-specific agent name (e.g., 'trellis-plan' for OpenCode)
"""
mapping = self._AGENT_NAME_MAP.get(self.platform, {})
return mapping.get(agent, agent)
# =========================================================================
# Agent Path
# =========================================================================
@property
def config_dir_name(self) -> str:
"""Get platform-specific config directory name.
Returns:
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"
elif self.platform == "cursor":
return ".cursor"
elif self.platform == "iflow":
return ".iflow"
elif self.platform == "codex":
return ".codex"
elif self.platform == "kilo":
return ".kilocode"
elif self.platform == "kiro":
return ".kiro"
elif self.platform == "gemini":
return ".gemini"
elif self.platform == "antigravity":
return ".agent"
elif self.platform == "devin":
return ".devin"
elif self.platform == "qoder":
return ".qoder"
elif self.platform == "codebuddy":
return ".codebuddy"
elif self.platform == "copilot":
return ".github/copilot"
elif self.platform == "droid":
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"
def get_config_dir(self, project_root: Path) -> Path:
"""Get platform-specific config directory.
Args:
project_root: Project root directory
Returns:
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
def get_agent_path(self, agent: str, project_root: Path) -> Path:
"""Get path to agent definition file.
Args:
agent: Agent name (original, before mapping)
project_root: Project root directory
Returns:
Path to agent definition file (.md for most platforms, .toml for Codex)
"""
mapped_name = self.get_agent_name(agent)
if self.platform == "codex":
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.toml"
return self.get_config_dir(project_root) / "agents" / f"{mapped_name}.md"
def get_commands_path(self, project_root: Path, *parts: str) -> Path:
"""Get path to commands directory or specific command file.
Args:
project_root: Project root directory
*parts: Additional path parts (e.g., 'trellis', 'finish-work.md')
Returns:
Path to commands directory or file
Note:
Cursor uses prefix naming: .cursor/commands/trellis-<name>.md
Antigravity uses workflow directory: .agent/workflows/<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
"""
if self.platform == "pi":
prompts_dir = self.get_config_dir(project_root) / "prompts"
if not parts:
return prompts_dir
if len(parts) >= 2 and parts[0] == "trellis":
filename = parts[-1]
if filename.endswith(".md"):
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)
# 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
if len(parts) >= 2 and parts[0] == "trellis":
filename = parts[-1]
return workflow_dir / f"trellis-{filename}"
return workflow_dir / Path(*parts)
if self.platform in ("antigravity", "kilo"):
workflow_dir = self.get_config_dir(project_root) / "workflows"
if not parts:
return workflow_dir
if len(parts) >= 2 and parts[0] == "trellis":
filename = parts[-1]
return workflow_dir / filename
return workflow_dir / Path(*parts)
if self.platform == "copilot":
prompts_dir = project_root / ".github" / "prompts"
if not parts:
return prompts_dir
if len(parts) >= 2 and parts[0] == "trellis":
filename = parts[-1]
if filename.endswith(".md"):
filename = filename[:-3]
return prompts_dir / f"{filename}.prompt.md"
return prompts_dir / Path(*parts)
if not parts:
return self.get_config_dir(project_root) / "commands"
# Cursor uses prefix naming instead of subdirectory
if self.platform == "cursor" and len(parts) >= 2 and parts[0] == "trellis":
# Convert trellis/<name>.md to trellis-<name>.md
filename = parts[-1]
return (
self.get_config_dir(project_root) / "commands" / f"trellis-{filename}"
)
return self.get_config_dir(project_root) / "commands" / Path(*parts)
def get_trellis_command_path(self, name: str) -> str:
"""Get relative path to a trellis command file.
Args:
name: Command name without extension (e.g., 'finish-work', 'check')
Returns:
Relative path string for use in JSONL entries
Note:
Cursor: .cursor/commands/trellis-<name>.md
Codex: .agents/skills/trellis-<name>/SKILL.md
Kiro: .kiro/skills/trellis-<name>/SKILL.md
Gemini: .gemini/commands/trellis/<name>.toml
Antigravity: .agent/workflows/<name>.md
Devin: .devin/workflows/trellis-<name>.md
Pi: .pi/prompts/trellis-<name>.md
Others: .{platform}/commands/trellis/<name>.md
"""
if self.platform == "cursor":
return f".cursor/commands/trellis-{name}.md"
elif self.platform == "codex":
# 0.5.0-beta.0 renamed all skill dirs to add the `trellis-` prefix
# (see that release's manifest for the 60+ rename entries).
return f".agents/skills/trellis-{name}/SKILL.md"
elif self.platform == "kiro":
return f".kiro/skills/trellis-{name}/SKILL.md"
elif self.platform == "gemini":
return f".gemini/commands/trellis/{name}.toml"
elif self.platform == "antigravity":
return f".agent/workflows/{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":
return f".github/prompts/{name}.prompt.md"
elif self.platform == "droid":
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"
# =========================================================================
# Environment Variables
# =========================================================================
def get_non_interactive_env(self) -> dict[str, str]:
"""Get environment variables for non-interactive mode.
Returns:
Dict of environment variables to set
"""
if self.platform == "opencode":
return {"OPENCODE_NON_INTERACTIVE": "1"}
elif self.platform == "iflow":
return {"IFLOW_NON_INTERACTIVE": "1"}
elif self.platform == "codex":
return {"CODEX_NON_INTERACTIVE": "1"}
elif self.platform == "kiro":
return {"KIRO_NON_INTERACTIVE": "1"}
elif self.platform == "gemini":
return {} # Gemini CLI doesn't have a non-interactive env var
elif self.platform == "antigravity":
return {}
elif self.platform == "devin":
return {}
elif self.platform == "qoder":
return {}
elif self.platform == "codebuddy":
return {}
elif self.platform == "copilot":
return {}
elif self.platform == "droid":
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"}
# =========================================================================
# CLI Command Building
# =========================================================================
def build_run_command(
self,
agent: str,
prompt: str,
session_id: str | None = None,
skip_permissions: bool = True,
verbose: bool = True,
json_output: bool = True,
) -> list[str]:
"""Build CLI command for running an agent.
Args:
agent: Agent name (will be mapped if needed)
prompt: Prompt to send to the agent
session_id: Optional session ID (Claude Code only for creation)
skip_permissions: Whether to skip permission prompts
verbose: Whether to enable verbose output
json_output: Whether to use JSON output format
Returns:
List of command arguments
"""
mapped_agent = self.get_agent_name(agent)
if self.platform == "opencode":
cmd = ["opencode", "run"]
cmd.extend(["--agent", mapped_agent])
# Note: OpenCode 'run' mode is non-interactive by default
# No equivalent to Claude Code's --dangerously-skip-permissions
# See: https://github.com/anomalyco/opencode/issues/9070
if json_output:
cmd.extend(["--format", "json"])
if verbose:
cmd.extend(["--log-level", "DEBUG", "--print-logs"])
# Note: OpenCode doesn't support --session-id on creation
# Session ID must be extracted from logs after startup
cmd.append(prompt)
elif self.platform == "iflow":
cmd = ["iflow", "-y", "-p"]
cmd.append(f"${mapped_agent} {prompt}")
elif self.platform == "codex":
cmd = ["codex", "exec"]
cmd.append(prompt)
elif self.platform == "kiro":
cmd = ["kiro", "run", prompt]
elif self.platform == "gemini":
cmd = ["gemini"]
cmd.append(prompt)
elif self.platform == "antigravity":
raise ValueError(
"Antigravity workflows are UI slash commands; CLI agent run is not supported."
)
elif self.platform == "devin":
raise ValueError(
"Devin workflows are UI slash commands; CLI agent run is not supported."
)
elif self.platform == "qoder":
cmd = ["qodercli", "-p", prompt]
elif self.platform == "codebuddy":
raise ValueError(
"CodeBuddy does not support non-interactive mode (no CLI agent)"
)
elif self.platform == "copilot":
raise ValueError(
"GitHub Copilot is IDE-only; CLI agent run is not supported."
)
elif self.platform == "droid":
raise ValueError(
"Factory Droid CLI agent run is not yet supported."
)
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"]
cmd.extend(["--agent", mapped_agent])
if session_id:
cmd.extend(["--session-id", session_id])
if skip_permissions:
cmd.append("--dangerously-skip-permissions")
if json_output:
cmd.extend(["--output-format", "stream-json"])
if verbose:
cmd.append("--verbose")
cmd.append(prompt)
return cmd
def build_resume_command(self, session_id: str) -> list[str]:
"""Build CLI command for resuming a session.
Args:
session_id: Session ID to resume (ignored for iFlow)
Returns:
List of command arguments
"""
if self.platform == "opencode":
return ["opencode", "run", "--session", session_id]
elif self.platform == "iflow":
# iFlow uses -c to continue most recent conversation
# session_id is ignored as iFlow doesn't support session IDs
return ["iflow", "-c"]
elif self.platform == "codex":
return ["codex", "resume", session_id]
elif self.platform == "kiro":
return ["kiro", "resume", session_id]
elif self.platform == "gemini":
return ["gemini", "--resume", session_id]
elif self.platform == "antigravity":
raise ValueError(
"Antigravity workflows are UI slash commands; CLI resume is not supported."
)
elif self.platform == "devin":
raise ValueError(
"Devin workflows are UI slash commands; CLI resume is not supported."
)
elif self.platform == "qoder":
return ["qodercli", "--resume", session_id]
elif self.platform == "codebuddy":
raise ValueError(
"CodeBuddy does not support non-interactive mode (no CLI agent)"
)
elif self.platform == "copilot":
raise ValueError(
"GitHub Copilot is IDE-only; CLI resume is not supported."
)
elif self.platform == "droid":
raise ValueError(
"Factory Droid CLI resume is not yet supported."
)
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]
def get_resume_command_str(self, session_id: str, cwd: str | None = None) -> str:
"""Get human-readable resume command string.
Args:
session_id: Session ID to resume
cwd: Optional working directory to cd into
Returns:
Command string for display
"""
cmd = self.build_resume_command(session_id)
cmd_str = " ".join(cmd)
if cwd:
return f"cd {cwd} && {cmd_str}"
return cmd_str
# =========================================================================
# Platform Detection Helpers
# =========================================================================
@property
def is_opencode(self) -> bool:
"""Check if platform is OpenCode."""
return self.platform == "opencode"
@property
def is_claude(self) -> bool:
"""Check if platform is Claude Code."""
return self.platform == "claude"
@property
def is_cursor(self) -> bool:
"""Check if platform is Cursor."""
return self.platform == "cursor"
@property
def is_iflow(self) -> bool:
"""Check if platform is iFlow CLI."""
return self.platform == "iflow"
@property
def cli_name(self) -> str:
"""Get CLI executable name.
Note: Cursor doesn't have a CLI tool, returns None-like value.
"""
if self.is_opencode:
return "opencode"
elif self.is_cursor:
return "cursor" # Note: Cursor is IDE-only, no CLI
elif self.platform == "iflow":
return "iflow"
elif self.platform == "kiro":
return "kiro"
elif self.platform == "gemini":
return "gemini"
elif self.platform == "antigravity":
return "agy"
elif self.platform == "devin":
return "devin"
elif self.platform == "qoder":
return "qodercli"
elif self.platform == "codebuddy":
return "codebuddy"
elif self.platform == "copilot":
return "copilot"
elif self.platform == "droid":
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"
@property
def supports_cli_agents(self) -> bool:
"""Check if platform supports running agents via CLI.
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",
"grok",
"kimi",
)
@property
def requires_agent_definition_file(self) -> bool:
"""Check if platform requires an agent definition file (.md/.toml) to run.
Claude Code, OpenCode, iFlow: require agent .md files (--agent flag).
Codex: auto-discovers agents from .codex/agents/*.toml, no --agent flag.
"""
return self.platform in ("claude", "opencode", "iflow")
# =========================================================================
# Session ID Handling
# =========================================================================
@property
def supports_session_id_on_create(self) -> bool:
"""Check if platform supports specifying session ID on creation.
Claude Code: Yes (--session-id)
OpenCode: No (auto-generated, extract from logs)
iFlow: No (no session ID support)
"""
return self.platform == "claude"
def extract_session_id_from_log(self, log_content: str) -> str | None:
"""Extract session ID from log output (OpenCode only).
OpenCode generates session IDs in format: ses_xxx
Args:
log_content: Log file content
Returns:
Session ID if found, None otherwise
"""
import re
# OpenCode session ID pattern
match = re.search(r"ses_[a-zA-Z0-9]+", log_content)
if match:
return match.group(0)
return None
# =============================================================================
# Factory Function
# =============================================================================
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', '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",
"cursor",
"iflow",
"codex",
"kilo",
"kiro",
"gemini",
"antigravity",
"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', 'devin', 'qoder', 'codebuddy', 'copilot', 'droid', 'pi', 'trae', 'omp', 'grok', or 'kimi')"
)
return CLIAdapter(platform=platform) # type: ignore
_ALL_PLATFORM_CONFIG_DIRS = (
".claude",
".cursor",
".iflow",
".opencode",
".codex",
".kilocode",
".kiro",
".gemini",
".agent",
".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, Devin, or other platforms."""
def _has_other_platform_dir(project_root: Path, exclude: set[str]) -> bool:
"""Check if any platform config dir exists besides those in *exclude*."""
return any(
(project_root / d).is_dir()
for d in _ALL_PLATFORM_CONFIG_DIRS
if d not in exclude
)
def detect_platform(project_root: Path) -> Platform:
"""Auto-detect platform based on existing config directories.
Detection order:
1. TRELLIS_PLATFORM environment variable (if set)
2. .opencode directory exists → opencode
3. .iflow directory exists → iflow
4. .cursor directory exists (without .claude) → cursor
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. .devin/workflows (or legacy .windsurf/workflows) exists and no other platform dirs → devin
11. .codebuddy directory exists → codebuddy
12. .qoder directory exists → qoder
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', '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",
"cursor",
"iflow",
"codex",
"kilo",
"kiro",
"gemini",
"antigravity",
"devin",
"qoder",
"codebuddy",
"copilot",
"droid",
"pi",
"trae",
"omp",
"grok",
"kimi",
):
return env_platform # type: ignore
# Check for .opencode directory (OpenCode-specific)
if (project_root / ".opencode").is_dir():
return "opencode"
# Check for .iflow directory (iFlow-specific)
if (project_root / ".iflow").is_dir():
return "iflow"
# Check for .cursor directory (Cursor-specific)
# Only detect as cursor if .claude doesn't exist (to avoid confusion)
if (project_root / ".cursor").is_dir() and not (project_root / ".claude").is_dir():
return "cursor"
# Check for .gemini directory (Gemini CLI-specific)
if (project_root / ".gemini").is_dir():
return "gemini"
# Check for .codex directory (Codex-specific)
# .agents/skills/ alone does NOT trigger codex detection (it's a shared standard)
if (project_root / ".codex").is_dir() and not _has_other_platform_dir(
project_root, {".codex", ".agents"}
):
return "codex"
# Check for .kilocode directory (Kilo-specific)
if (project_root / ".kilocode").is_dir():
return "kilo"
# Check for Kiro skills directory only when no other platform config exists
if (project_root / ".kiro" / "skills").is_dir() and not _has_other_platform_dir(
project_root, {".kiro"}
):
return "kiro"
# Check for Antigravity workflow directory only when no other platform config exists
if (
project_root / ".agent" / "workflows"
).is_dir() and not _has_other_platform_dir(
project_root, {".agent", ".gemini"}
):
return "antigravity"
# 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 / ".devin" / "workflows").is_dir()
or (project_root / ".windsurf" / "workflows").is_dir()
) and not _has_other_platform_dir(
project_root, {".devin", ".windsurf"}
):
return "devin"
# Check for .codebuddy directory (CodeBuddy-specific)
if (project_root / ".codebuddy").is_dir():
return "codebuddy"
# Check for .qoder directory (Qoder-specific)
if (project_root / ".qoder").is_dir():
return "qoder"
# Check for .github/copilot directory (GitHub Copilot-specific)
if (project_root / ".github" / "copilot").is_dir():
return "copilot"
# Check for .factory directory (Factory Droid-specific)
if (project_root / ".factory").is_dir():
return "droid"
# Check for .pi directory (Pi Agent-specific)
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
# shared skills were committed to git. Must guard against the case
# where .claude/ or any other platform dir also exists — .agents/skills/
# can legitimately coexist with any platform as a shared consumption
# layer for Amp/Cline/Warp/etc.
agents_skills = project_root / ".agents" / "skills"
if agents_skills.is_dir() and not _has_other_platform_dir(
project_root, set()
):
try:
for entry in agents_skills.iterdir():
if entry.is_dir() and entry.name.startswith("trellis-"):
return "codex"
except OSError:
pass
return "claude"
def get_cli_adapter_auto(project_root: Path) -> CLIAdapter:
"""Get CLI adapter with auto-detected platform.
Args:
project_root: Project root directory
Returns:
CLIAdapter instance for detected platform
"""
platform = detect_platform(project_root)
return CLIAdapter(platform=platform)
+482
View File
@@ -0,0 +1,482 @@
#!/usr/bin/env python3
"""
Trellis configuration reader.
Reads settings from .trellis/config.yaml with sensible defaults.
"""
from __future__ import annotations
import sys
from pathlib import Path
from .paths import DIR_WORKFLOW, get_repo_root
from .trellis_config import parse_simple_yaml
# The YAML subset parser lives in trellis_config.py — it imports nothing from
# this package, so hooks can load it as a single standalone file. Two byte-
# equivalent copies is a drift hazard, not a feature.
# Defaults
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"
TRUE_CONFIG_VALUES = ("true", "yes", "1", "on")
FALSE_CONFIG_VALUES = ("false", "no", "0", "off")
def coerce_config_bool(
value: object,
default: bool,
label: str,
) -> bool:
"""Coerce a config value to a bool, warning on anything unrecognized.
The parser stores every value as a string, so ``git: yes`` arrives as
``"yes"``. Every boolean config key goes through this one helper: an
accepted-here/rejected-there split means a user writing a perfectly
reasonable YAML boolean silently gets the opposite branch.
Args:
value: Raw value from the parsed config.
default: Returned when the value is unrecognized.
label: Config key name, used in the warning.
"""
if isinstance(value, bool):
return value
s = str(value).strip().lower()
if s in TRUE_CONFIG_VALUES:
return True
if s in FALSE_CONFIG_VALUES:
return False
print(
f"[WARN] invalid {label} value: {value!r}; using {str(default).lower()} (default)",
file=sys.stderr,
)
return default
def _is_true_config_value(value: object, label: str = "config flag") -> bool:
"""Return True when a config value represents an enabled flag."""
if value is None:
return False
return coerce_config_bool(value, False, label)
def _get_config_path(repo_root: Path | None = None) -> Path:
"""Get path to config.yaml."""
root = repo_root or get_repo_root()
return root / DIR_WORKFLOW / CONFIG_FILE
def _load_config(repo_root: Path | None = None) -> dict:
"""Load and parse config.yaml. Returns empty dict on any error.
Fail-open, matching ``trellis_config.read_trellis_config``: a malformed
config must not take down ``task.py create``. A parse failure is reported
once on stderr so it is not invisible.
"""
config_file = _get_config_path(repo_root)
try:
content = config_file.read_text(encoding="utf-8")
except (OSError, IOError):
return {}
try:
parsed = parse_simple_yaml(content, source=str(config_file))
except Exception as e:
print(
f"[WARN] could not parse {config_file}: {type(e).__name__}: {e}; "
"using defaults",
file=sys.stderr,
)
return {}
return parsed if isinstance(parsed, dict) else {}
def get_session_commit_message(repo_root: Path | None = None) -> str:
"""Get the commit message for auto-committing session records."""
config = _load_config(repo_root)
return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
def get_max_journal_lines(repo_root: Path | None = None) -> int:
"""Get the maximum lines per journal file."""
config = _load_config(repo_root)
value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
try:
return int(value)
except (ValueError, TypeError):
return DEFAULT_MAX_JOURNAL_LINES
def get_session_auto_commit(repo_root: Path | None = None) -> bool:
"""Whether scripts should auto-stage + auto-commit session/task changes.
Governs both ``add_session.py:_auto_commit_workspace`` and
``task_store.py:_auto_commit_archive``.
Default: ``True`` (existing behavior — auto-stage + auto-commit).
Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip
auto-staging entirely; the journal/archive files are still written to
disk, but the user manages ``git add`` / ``git commit`` themselves.
Accepts native YAML booleans (``true`` / ``false``) and the string
aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive).
Invalid values fall back to ``True`` with a stderr warning.
"""
config = _load_config(repo_root)
raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT)
return coerce_config_bool(
raw, DEFAULT_SESSION_AUTO_COMMIT, "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.
Args:
event: Event name (e.g. "after_create", "after_archive").
repo_root: Repository root path.
A hook the user believes is installed and which silently never runs is the
worst outcome for this feature, so a declared-but-unusable shape warns
instead of returning an empty list quietly.
Returns:
List of shell commands to execute, empty if none configured.
"""
config = _load_config(repo_root)
hooks = config.get("hooks")
if hooks is None:
return []
if not isinstance(hooks, dict):
print(
f"[WARN] ignoring `hooks` in config.yaml: expected a mapping of "
f"event -> list of commands, got {hooks!r}",
file=sys.stderr,
)
return []
commands = hooks.get(event)
if commands is None:
return []
if isinstance(commands, list):
return [str(c) for c in commands]
# `after_create: echo hi` instead of a `- ` list — parses fine, registers
# nothing.
print(
f"[WARN] ignoring hook `{event}` in config.yaml: expected a list of "
f"commands, got {commands!r}. Write it as:\n"
f" hooks:\n {event}:\n - {commands}",
file=sys.stderr,
)
return []
# =============================================================================
# Monorepo / Packages
# =============================================================================
def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None:
"""Get monorepo package declarations.
Returns:
Dict mapping package name to its config (path, type, etc.),
or None if not configured (single-repo mode).
Example return:
{"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}}
"""
config = _load_config(repo_root)
packages = config.get("packages")
if not isinstance(packages, dict):
return None
# Ensure each value is a dict (filter out scalar entries)
filtered = {k: v for k, v in packages.items() if isinstance(v, dict)}
if not filtered:
return None
return filtered
def get_default_package(repo_root: Path | None = None) -> str | None:
"""Get the default package name from config.
Returns:
Package name string, or None if not configured.
"""
config = _load_config(repo_root)
value = config.get("default_package")
return str(value) if value else None
def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]:
"""Get packages that are git submodules.
Returns:
Dict mapping package name to its path for submodule-type packages.
Empty dict if none configured.
Example return:
{"docs-site": "docs-site"}
"""
packages = get_packages(repo_root)
if packages is None:
return {}
return {
name: cfg.get("path", name)
for name, cfg in packages.items()
if cfg.get("type") == "submodule"
}
def get_git_packages(repo_root: Path | None = None) -> dict[str, str]:
"""Get packages that have their own independent git repository.
These are sub-directories with their own .git (not submodules),
marked with ``git: true`` in config.yaml.
Returns:
Dict mapping package name to its path for git-repo packages.
Empty dict if none configured.
Example config::
packages:
backend:
path: iqs
git: true
Example return::
{"backend": "iqs"}
"""
packages = get_packages(repo_root)
if packages is None:
return {}
return {
name: cfg.get("path", name)
for name, cfg in packages.items()
if _is_true_config_value(cfg.get("git"), f"packages.{name}.git")
}
def is_monorepo(repo_root: Path | None = None) -> bool:
"""Check if the project is configured as a monorepo (has packages in config)."""
return get_packages(repo_root) is not None
def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str:
"""Get the spec directory base path relative to .trellis/.
Single-repo: returns "spec"
Monorepo with package: returns "spec/<package>"
Monorepo without package: returns "spec" (caller should specify package)
"""
if package and is_monorepo(repo_root):
return f"spec/{package}"
return "spec"
def validate_package(package: str, repo_root: Path | None = None) -> bool:
"""Check if a package name is valid in this project.
Single-repo (no packages configured): always returns True.
Monorepo: returns True only if package exists in config.yaml packages.
"""
packages = get_packages(repo_root)
if packages is None:
return True # Single-repo, no validation needed
return package in packages
def resolve_package(
task_package: str | None = None,
repo_root: Path | None = None,
) -> str | None:
"""Resolve package from inferred sources with validation.
Checks in order: task_package → default_package.
Invalid inferred values print a warning to stderr and are skipped.
Returns:
Resolved package name, or None if no valid package found.
Note:
CLI --package should be validated separately by the caller
(fail-fast with available packages list on error).
"""
packages = get_packages(repo_root)
if packages is None:
return None # Single-repo, no package needed
# Try task_package (guard against non-string values from malformed JSON)
if task_package and isinstance(task_package, str):
if task_package in packages:
return task_package
print(
f"Warning: task.json package '{task_package}' not found in config, skipping",
file=sys.stderr,
)
# Try default_package
default = get_default_package(repo_root)
if default:
if default in packages:
return default
print(
f"Warning: default_package '{default}' not found in config, skipping",
file=sys.stderr,
)
return None
def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None:
"""Get session.spec_scope configuration.
Returns:
list[str]: Package names to include in spec scanning.
str: "active_task" to use current task's package.
None: No scope configured (scan all packages).
"""
config = _load_config(repo_root)
session = config.get("session")
if not isinstance(session, dict):
return None
scope = session.get("spec_scope")
if scope is None:
return None
if isinstance(scope, str):
return scope # e.g. "active_task"
if isinstance(scope, list):
return [str(s) for s in scope]
return None
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Developer management utilities.
Provides:
init_developer - Initialize developer
ensure_developer - Ensure developer is initialized (exit if not)
show_developer_info - Show developer information
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from .paths import (
DEVELOPER_HINT,
DIR_WORKFLOW,
DIR_WORKSPACE,
DIR_TASKS,
FILE_DEVELOPER,
FILE_JOURNAL_PREFIX,
get_repo_root,
get_developer,
check_developer,
)
# =============================================================================
# Developer Initialization
# =============================================================================
def init_developer(name: str, repo_root: Path | None = None) -> bool:
"""Initialize developer.
Creates:
- .trellis/.developer file with developer info
- .trellis/workspace/<name>/ directory structure
- Initial journal file and index.md
Args:
name: Developer name.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
True on success, False on error.
"""
if not name:
print("Error: developer name is required", file=sys.stderr)
return False
if repo_root is None:
repo_root = get_repo_root()
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
workspace_dir = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / name
# Create .developer file
initialized_at = datetime.now().isoformat()
try:
dev_file.write_text(
f"name={name}\ninitialized_at={initialized_at}\n",
encoding="utf-8"
)
except (OSError, IOError) as e:
print(f"Error: Failed to create .developer file: {e}", file=sys.stderr)
return False
# Create workspace directory structure
try:
workspace_dir.mkdir(parents=True, exist_ok=True)
except (OSError, IOError) as e:
print(f"Error: Failed to create workspace directory: {e}", file=sys.stderr)
return False
# Create initial journal file
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
if not journal_file.exists():
today = datetime.now().strftime("%Y-%m-%d")
journal_content = f"""# Journal - {name} (Part 1)
> AI development session journal
> Started: {today}
---
"""
try:
journal_file.write_text(journal_content, encoding="utf-8")
except (OSError, IOError) as e:
print(f"Error: Failed to create journal file: {e}", file=sys.stderr)
return False
# Create index.md with markers for auto-update
index_file = workspace_dir / "index.md"
if not index_file.exists():
index_content = f"""# Workspace Index - {name}
> Journal tracking for AI development sessions.
---
## Current Status
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 0
- **Last Active**: -
<!-- @@@/auto:current-status -->
---
## Active Documents
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~0 | Active |
<!-- @@@/auto:active-documents -->
---
## Session History
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
<!-- @@@/auto:session-history -->
---
## Notes
- Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions
"""
try:
index_file.write_text(index_content, encoding="utf-8")
except (OSError, IOError) as e:
print(f"Error: Failed to create index.md: {e}", file=sys.stderr)
return False
print(f"Developer initialized: {name}")
print(f" .developer file: {dev_file}")
print(f" Workspace dir: {workspace_dir}")
return True
def ensure_developer(repo_root: Path | None = None) -> None:
"""Ensure developer is initialized, exit if not.
Args:
repo_root: Repository root path. Defaults to auto-detected.
"""
if repo_root is None:
repo_root = get_repo_root()
if not check_developer(repo_root):
print("Error: Developer not initialized.", file=sys.stderr)
print(f"Run: python3 ./{DIR_WORKFLOW}/scripts/init_developer.py <your-name>", file=sys.stderr)
print(DEVELOPER_HINT, file=sys.stderr)
sys.exit(1)
def show_developer_info(repo_root: Path | None = None) -> None:
"""Show developer information.
Args:
repo_root: Repository root path. Defaults to auto-detected.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
if not developer:
print("Developer: (not initialized)")
else:
print(f"Developer: {developer}")
print(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
print(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
# =============================================================================
# Main Entry (for testing)
# =============================================================================
if __name__ == "__main__":
show_developer_info()
+203
View File
@@ -0,0 +1,203 @@
"""
Git command execution utility.
Single source of truth for running git commands across all Trellis scripts.
"""
from __future__ import annotations
import subprocess
import time
from pathlib import Path
# Bounded retry for transient `.git/index.lock` contention. Another process
# (IDE git integration, status daemon, a concurrent Trellis session) can hold
# the lock for a fraction of a second; three attempts spread over ~1.5s ride
# that out without making a genuinely stuck lock hang the command. One sleep
# per retry, so the attempt count follows the backoff tuple.
INDEX_LOCK_RETRY_BACKOFF = (0.5, 1.0)
INDEX_LOCK_RETRY_ATTEMPTS = len(INDEX_LOCK_RETRY_BACKOFF) + 1
# Whether a checkout is a linked worktree cannot change while a script runs, and
# the answer costs two subprocesses. Developer-identity resolution asks several
# times per command whenever the local `.developer` file is absent, so memoize.
_CACHE_MISS = object()
_MAIN_WORKTREE_CACHE: dict[Path, Path | None] = {}
def run_git(
args: list[str],
cwd: Path | None = None,
timeout: float | None = None,
) -> tuple[int, str, str]:
"""Run a git command and return (returncode, stdout, stderr).
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
consistent output across all platforms (Windows, macOS, Linux). Callers
may provide a timeout for best-effort probes; normal Git operations remain
unbounded by default.
"""
try:
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
result = subprocess.run(
git_args,
cwd=cwd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
return 1, "", str(e)
def stderr_indicates_index_lock(stderr: str) -> bool:
"""git failed because another process holds `.git/index.lock`."""
if not stderr:
return False
return "index.lock" in stderr.lower()
def run_git_retry_index_lock(
args: list[str],
cwd: Path | None = None,
timeout: float | None = None,
) -> tuple[int, str, str]:
"""Run a git command, retrying only while `.git/index.lock` is held.
Any other non-zero exit returns immediately — a retry loop around real
failures (bad path, nothing to commit, hook rejection) just delays the
error. Returns the last (returncode, stdout, stderr).
"""
rc, out, err = run_git(args, cwd=cwd, timeout=timeout)
attempt = 1
while (
rc != 0
and attempt < INDEX_LOCK_RETRY_ATTEMPTS
and stderr_indicates_index_lock(err)
):
time.sleep(INDEX_LOCK_RETRY_BACKOFF[attempt - 1])
attempt += 1
rc, out, err = run_git(args, cwd=cwd, timeout=timeout)
return rc, out, err
def index_lock_path(repo_root: Path) -> str:
"""Path of the lock file git is contending on, for diagnostics.
Asks git so worktrees and `GIT_DIR` setups name the real file rather
than a `.git/` guess that does not exist there.
"""
rc, out, _ = run_git(["rev-parse", "--git-path", "index.lock"], cwd=repo_root)
if rc == 0 and out.strip():
return out.strip()
return ".git/index.lock"
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 current_branch_name(repo_root: Path) -> str | None:
"""Return the checked-out branch name, or None when there isn't one.
Empty output covers detached HEAD and "not a git repository" alike, and
callers treat both the same way: there is no branch worth recording.
"""
rc, out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
if rc != 0:
return None
return out.strip() or None
def has_git_remote(repo_root: Path) -> bool:
"""Whether the repository has at least one configured remote."""
rc, out, _ = run_git(["remote"], cwd=repo_root)
return rc == 0 and bool(out.strip())
def main_worktree_root(repo_root: Path) -> Path | None:
"""Root of the main working tree when `repo_root` is a linked worktree.
Returns None in the main working tree itself, outside a git repository, and
for a linked worktree of a bare repository (no main checkout to point at).
`git worktree list --porcelain` reports the main working tree as its first
record, so git identifies it rather than this code deriving it from the
`.git` layout. Deriving it — taking the parent of `--git-common-dir` — is
wrong for a bare repository that happens to sit inside an unrelated
checkout (`~/repos/project.git` under a `~/repos` that is itself a repo):
the parent is a real checkout with a real `.developer`, so the guess is
indistinguishable from a hit and identity leaks across repositories.
"""
cached = _MAIN_WORKTREE_CACHE.get(repo_root, _CACHE_MISS)
if cached is not _CACHE_MISS:
return cached # type: ignore[return-value]
result = _probe_main_worktree_root(repo_root)
_MAIN_WORKTREE_CACHE[repo_root] = result
return result
def _probe_main_worktree_root(repo_root: Path) -> Path | None:
rc_list, listing, _ = run_git(["worktree", "list", "--porcelain"], cwd=repo_root)
rc_top, toplevel, _ = run_git(["rev-parse", "--show-toplevel"], cwd=repo_root)
if rc_list != 0 or rc_top != 0:
return None
lines = listing.splitlines()
if not lines or not lines[0].startswith("worktree "):
return None
# Records are blank-line separated; a `bare` attribute on the first one
# means the "main working tree" is a bare repo with nothing to inherit.
for line in lines[1:]:
if not line.strip():
break
if line.strip() == "bare":
return None
try:
main_root = Path(lines[0][len("worktree ") :].strip()).resolve()
current_root = Path(toplevel.strip()).resolve()
except (OSError, ValueError):
return None
if main_root == current_root:
return None
return main_root
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
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Git and Session Context utilities.
Entry shim — delegates to session_context and packages_context.
Provides:
output_json - Output context in JSON format
output_text - Output context in text format
"""
from __future__ import annotations
import json
from .git import run_git
from .session_context import (
get_context_json,
get_context_text,
get_context_record_json,
get_context_text_record,
output_json,
output_text,
)
from .packages_context import (
get_context_packages_text,
get_context_packages_json,
)
from .trellis_config import read_trellis_config
from .workflow_phase import (
filter_platform,
get_phase_index,
get_step,
resolve_effective_platform,
)
# Backward-compatible alias — external modules import this name
_run_git_command = run_git
# =============================================================================
# Main Entry
# =============================================================================
def main() -> None:
"""CLI entry point."""
import argparse
parser = argparse.ArgumentParser(description="Get Session Context for AI Agent")
parser.add_argument(
"--json",
"-j",
action="store_true",
help="Output in JSON format (works with any --mode)",
)
parser.add_argument(
"--mode",
"-m",
choices=["default", "record", "packages", "phase"],
default="default",
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
)
parser.add_argument(
"--step",
help="Step id for --mode phase, e.g. 1.1, 2.2. Omit to get the Phase Index.",
)
parser.add_argument(
"--platform",
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
)
args = parser.parse_args()
if args.mode == "record":
if args.json:
print(json.dumps(get_context_record_json(), indent=2, ensure_ascii=False))
else:
print(get_context_text_record())
elif args.mode == "packages":
if args.json:
print(json.dumps(get_context_packages_json(), indent=2, ensure_ascii=False))
else:
print(get_context_packages_text())
elif args.mode == "phase":
content = get_step(args.step) if args.step else get_phase_index()
if not content.strip():
if args.step:
parser.exit(2, f"Step not found: {args.step}\n")
else:
parser.exit(2, "Phase Index section not found in workflow.md\n")
if args.platform:
effective = resolve_effective_platform(
args.platform, read_trellis_config()
)
content = filter_platform(content, effective)
print(content, end="")
else:
if args.json:
output_json()
else:
output_text()
if __name__ == "__main__":
main()
+160
View File
@@ -0,0 +1,160 @@
"""
File I/O utilities.
Provides read_json / write_json as the single source of truth for JSON file
operations, plus write_text_atomic for the Markdown state files (journal,
index.md) that carry durable session state.
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
JSON_READ_MISSING = "missing"
JSON_READ_INVALID = "invalid"
JSON_READ_UNREADABLE = "unreadable"
JSON_READ_NOT_OBJECT = "not-object"
JSON_READ_EMPTY = "empty"
JSON_READ_UNDECODABLE = "undecodable"
def read_json(path: Path) -> dict | None:
"""Read and parse a JSON file.
Returns None if the file doesn't exist, is invalid JSON, or can't be read.
Use this for optional reads only — a caller that is about to overwrite the
file, or that must tell a parse error from a permissions error, wants
read_json_checked instead.
"""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError, UnicodeDecodeError):
# UnicodeDecodeError is not an OSError. Without it here a non-UTF-8
# session file raises out of a tolerant read, so the hook path fails
# instead of degrading to "no active task".
return None
def read_json_checked(path: Path) -> tuple[dict | None, str | None]:
"""Read a JSON object, keeping the ways it can fail distinguishable.
Returns ``(data, None)`` on success, or ``(None, reason)`` where reason is
one of the ``JSON_READ_*`` constants. An empty object counts as a failure:
a state file that parses to ``{}`` carries none of the fields callers read,
and treating it as success would silently rebuild it from defaults.
"""
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None, JSON_READ_MISSING
except UnicodeDecodeError:
# Not an OSError, so it escaped both handlers and surfaced as a
# traceback. The point of this reader is that every failure mode stays
# nameable, and "not valid UTF-8" is a different repair from
# "not valid JSON".
return None, JSON_READ_UNDECODABLE
except OSError:
return None, JSON_READ_UNREADABLE
try:
data = json.loads(text)
except json.JSONDecodeError:
return None, JSON_READ_INVALID
if not isinstance(data, dict):
return None, JSON_READ_NOT_OBJECT
if not data:
return None, JSON_READ_EMPTY
return data, None
def describe_json_read_failure(path: Path, reason: str | None) -> tuple[str, str]:
"""Return ``(what happened, what to do)`` for a read_json_checked reason."""
if reason == JSON_READ_MISSING:
return (f"{path}: file not found", "Pass an existing task directory, or create the task first.")
if reason == JSON_READ_UNREADABLE:
return (
f"{path}: could not be read (permission denied or I/O error)",
"Check the file and directory permissions, then retry.",
)
if reason == JSON_READ_INVALID:
return (
f"{path}: not valid JSON",
f"Fix the syntax (e.g. `python3 -m json.tool {path}`), then retry.",
)
if reason == JSON_READ_NOT_OBJECT:
return (
f"{path}: top level is not a JSON object",
"Restore the file to a JSON object ({ ... }), then retry.",
)
if reason == JSON_READ_EMPTY:
return (
f"{path}: contains an empty JSON object",
"Restore the task fields (or recreate the task), then retry.",
)
if reason == JSON_READ_UNDECODABLE:
return (
f"{path}: not valid UTF-8 text",
"Re-save the file as UTF-8 (or restore it from git), then retry.",
)
return (f"{path}: could not be loaded", "Inspect the file, then retry.")
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.
"""
return write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
def write_text_atomic(path: Path, text: str) -> bool:
"""Write text to a file atomically (temp in same dir, then replace).
The same never-truncate-in-place guarantee as :func:`write_json`, for the
Markdown state files that hold durable session state (journal files,
index.md). A crash or Ctrl-C mid-write leaves the previous content intact
instead of a half-written record that no retry can classify.
Returns True on success, False on error.
"""
try:
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(text)
os.replace(tmp, path)
return True
except OSError:
try:
os.unlink(tmp)
except OSError:
pass
return False
except BaseException:
# Ctrl-C mid-write: drop the temp file, then let the interrupt through.
try:
os.unlink(tmp)
except OSError:
pass
raise
+45
View File
@@ -0,0 +1,45 @@
"""
Terminal output utilities: colors and structured logging.
Single source of truth for Colors and log_* functions
used across all Trellis scripts.
"""
from __future__ import annotations
class Colors:
"""ANSI color codes for terminal output."""
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
BLUE = "\033[0;34m"
CYAN = "\033[0;36m"
DIM = "\033[2m"
NC = "\033[0m" # No Color / Reset
def colored(text: str, color: str) -> str:
"""Apply ANSI color to text."""
return f"{color}{text}{Colors.NC}"
def log_info(msg: str) -> None:
"""Print info-level message with [INFO] prefix."""
print(f"{Colors.BLUE}[INFO]{Colors.NC} {msg}")
def log_success(msg: str) -> None:
"""Print success message with [SUCCESS] prefix."""
print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} {msg}")
def log_warn(msg: str) -> None:
"""Print warning message with [WARN] prefix."""
print(f"{Colors.YELLOW}[WARN]{Colors.NC} {msg}")
def log_error(msg: str) -> None:
"""Print error message with [ERROR] prefix."""
print(f"{Colors.RED}[ERROR]{Colors.NC} {msg}")
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""
Package discovery and context output.
Provides:
get_packages_info - Get structured package info
get_packages_section - Build PACKAGES text section
get_context_packages_text - Full packages text output (--mode packages)
get_context_packages_json - Full packages JSON output (--mode packages --json)
"""
from __future__ import annotations
from pathlib import Path
from .config import _is_true_config_value, get_default_package, get_packages, get_spec_scope
from .paths import (
DIR_SPEC,
DIR_WORKFLOW,
get_current_task,
get_repo_root,
)
from .tasks import load_task
# =============================================================================
# Internal Helpers
# =============================================================================
def _scan_spec_layers(spec_dir: Path, package: str | None = None) -> list[str]:
"""Scan spec directory for available layers (subdirectories).
For monorepo: scans spec/<package>/
For single-repo: scans spec/
"""
target = spec_dir / package if package else spec_dir
if not target.is_dir():
return []
return sorted(
d.name for d in target.iterdir() if d.is_dir() and d.name != "guides"
)
def _get_active_task_package(repo_root: Path) -> str | None:
"""Get the package field from the active task's task.json."""
current = get_current_task(repo_root)
if not current:
return None
ct = load_task(repo_root / current)
return ct.package if ct and ct.package else None
def _resolve_scope_set(
packages: dict,
spec_scope,
task_pkg: str | None,
default_pkg: str | None,
) -> set | None:
"""Resolve spec_scope to a set of allowed package names, or None for full scan."""
if not packages:
return None
if spec_scope is None:
return None
if isinstance(spec_scope, str) and spec_scope == "active_task":
if task_pkg and task_pkg in packages:
return {task_pkg}
if default_pkg and default_pkg in packages:
return {default_pkg}
return None
if isinstance(spec_scope, list):
valid = {e for e in spec_scope if e in packages}
if valid:
return valid
# All invalid: fallback
if task_pkg and task_pkg in packages:
return {task_pkg}
if default_pkg and default_pkg in packages:
return {default_pkg}
return None
return None
# =============================================================================
# Public Functions
# =============================================================================
def get_packages_info(repo_root: Path) -> list[dict]:
"""Get structured package info for monorepo projects.
Returns list of dicts with keys: name, path, type, default, specLayers,
isSubmodule, isGitRepo.
Returns empty list for single-repo projects.
"""
packages = get_packages(repo_root)
if not packages:
return []
default_pkg = get_default_package(repo_root)
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
result = []
for pkg_name, pkg_config in packages.items():
pkg_path = pkg_config.get("path", pkg_name) if isinstance(pkg_config, dict) else str(pkg_config)
pkg_type = pkg_config.get("type", "local") if isinstance(pkg_config, dict) else "local"
pkg_git = pkg_config.get("git", False) if isinstance(pkg_config, dict) else False
layers = _scan_spec_layers(spec_dir, pkg_name)
result.append({
"name": pkg_name,
"path": pkg_path,
"type": pkg_type,
"default": pkg_name == default_pkg,
"specLayers": layers,
"isSubmodule": pkg_type == "submodule",
"isGitRepo": _is_true_config_value(pkg_git),
})
return result
def get_packages_section(repo_root: Path) -> str:
"""Build the PACKAGES section for text output."""
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
pkg_info = get_packages_info(repo_root)
lines: list[str] = []
lines.append("## PACKAGES")
if not pkg_info:
lines.append("(single-repo mode)")
layers = _scan_spec_layers(spec_dir)
if layers:
lines.append(f"Spec layers: {', '.join(layers)}")
return "\n".join(lines)
default_pkg = get_default_package(repo_root)
for pkg in pkg_info:
layers_str = f" [{', '.join(pkg['specLayers'])}]" if pkg["specLayers"] else ""
submodule_tag = " (submodule)" if pkg["isSubmodule"] else ""
git_repo_tag = " (git repo)" if pkg["isGitRepo"] else ""
default_tag = " *" if pkg["default"] else ""
lines.append(
f"- {pkg['name']:<16} {pkg['path']:<20}{layers_str}{submodule_tag}{git_repo_tag}{default_tag}"
)
if default_pkg:
lines.append(f"Default package: {default_pkg}")
return "\n".join(lines)
def get_context_packages_text(repo_root: Path | None = None) -> str:
"""Get packages context as formatted text (for --mode packages)."""
if repo_root is None:
repo_root = get_repo_root()
pkg_info = get_packages_info(repo_root)
lines: list[str] = []
if not pkg_info:
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
lines.append("Single-repo project (no packages configured)")
lines.append("")
layers = _scan_spec_layers(spec_dir)
if layers:
lines.append(f"Spec layers: {', '.join(layers)}")
return "\n".join(lines)
# Resolve scope for annotations
packages_dict = get_packages(repo_root) or {}
default_pkg = get_default_package(repo_root)
spec_scope = get_spec_scope(repo_root)
task_pkg = _get_active_task_package(repo_root)
scope_set = _resolve_scope_set(packages_dict, spec_scope, task_pkg, default_pkg)
lines.append("## PACKAGES")
lines.append("")
for pkg in pkg_info:
default_tag = " (default)" if pkg["default"] else ""
type_tag = f" [{pkg['type']}]" if pkg["type"] != "local" else ""
git_tag = " [git repo]" if pkg["isGitRepo"] else ""
# Scope annotation
scope_tag = ""
if scope_set is not None and pkg["name"] not in scope_set:
scope_tag = " (out of scope)"
lines.append(f"### {pkg['name']}{default_tag}{type_tag}{git_tag}{scope_tag}")
lines.append(f"Path: {pkg['path']}")
if pkg["specLayers"]:
lines.append(f"Spec layers: {', '.join(pkg['specLayers'])}")
for layer in pkg["specLayers"]:
lines.append(f" - .trellis/spec/{pkg['name']}/{layer}/index.md")
else:
lines.append("Spec: not configured")
lines.append("")
# Also show shared guides
guides_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / "guides"
if guides_dir.is_dir():
lines.append("### Shared Guides (always included)")
lines.append("Path: .trellis/spec/guides/index.md")
lines.append("")
return "\n".join(lines)
def get_context_packages_json(repo_root: Path | None = None) -> dict:
"""Get packages context as a dictionary (for --mode packages --json)."""
if repo_root is None:
repo_root = get_repo_root()
pkg_info = get_packages_info(repo_root)
if not pkg_info:
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
layers = _scan_spec_layers(spec_dir)
return {
"mode": "single-repo",
"specLayers": layers,
}
default_pkg = get_default_package(repo_root)
spec_scope = get_spec_scope(repo_root)
task_pkg = _get_active_task_package(repo_root)
return {
"mode": "monorepo",
"packages": pkg_info,
"defaultPackage": default_pkg,
"specScope": spec_scope,
"activeTaskPackage": task_pkg,
}
+543
View File
@@ -0,0 +1,543 @@
#!/usr/bin/env python3
"""
Common path utilities for Trellis workflow.
Provides:
get_repo_root - Get repository root directory
get_developer - Get developer name
get_workspace_dir - Get developer workspace directory
get_tasks_dir - Get tasks directory
get_active_journal_file - Get current journal file
"""
from __future__ import annotations
import os
import re
from datetime import datetime
from pathlib import Path
from .git import main_worktree_root
# =============================================================================
# Path Constants (change here to rename directories)
# =============================================================================
# Directory names
DIR_WORKFLOW = ".trellis"
DIR_WORKSPACE = "workspace"
DIR_TASKS = "tasks"
DIR_ARCHIVE = "archive"
DIR_SPEC = "spec"
DIR_SCRIPTS = "scripts"
# File names
FILE_DEVELOPER = ".developer"
FILE_CURRENT_TASK = ".current-task"
FILE_TASK_JSON = "task.json"
FILE_JOURNAL_PREFIX = "journal-"
# Environment override for the developer identity, ahead of the .developer file.
ENV_DEVELOPER = "TRELLIS_DEVELOPER"
# Appended to every "no developer set" error so the two non-obvious sources are
# discoverable from the failure itself.
DEVELOPER_HINT = (
f" Or set {ENV_DEVELOPER}=<your-name> in the environment.\n"
f" A linked git worktree inherits {DIR_WORKFLOW}/{FILE_DEVELOPER} from its "
f"main checkout — run init_developer.py there to cover every worktree."
)
# =============================================================================
# Repository Root
# =============================================================================
def get_repo_root(start_path: Path | None = None) -> Path:
"""Find the nearest directory containing .trellis/ folder.
This handles nested git repos correctly (e.g., test project inside another repo).
Args:
start_path: Starting directory to search from. Defaults to current directory.
Returns:
Path to repository root, or current directory if no .trellis/ found.
"""
current = (start_path or Path.cwd()).resolve()
while current != current.parent:
if (current / DIR_WORKFLOW).is_dir():
return current
current = current.parent
# Fallback to current directory if no .trellis/ found
return Path.cwd().resolve()
# =============================================================================
# Developer
# =============================================================================
def _read_developer_file(dev_file: Path) -> str | None:
"""Read the `name=` field out of a .developer file, or None."""
if not dev_file.is_file():
return None
try:
content = dev_file.read_text(encoding="utf-8")
except (OSError, IOError):
return None
for line in content.splitlines():
if line.startswith("name="):
return line.split("=", 1)[1].strip() or None
return None
def get_developer(repo_root: Path | None = None) -> str | None:
"""Get the developer name for this checkout.
Resolution order, first hit wins (a CLI `--assignee` flag overrides all of
it, before this function is ever called):
1. The ``TRELLIS_DEVELOPER`` environment variable.
2. ``.trellis/.developer`` in this checkout.
3. ``.trellis/.developer`` in the main checkout, when this checkout is a
linked git worktree.
Step 3 exists because `.developer` is gitignored on purpose — it carries a
personal identity and no tracked file should. A fresh `git worktree add`
therefore starts with no identity file of its own, which used to make every
task.py command fail until init_developer.py was re-run per worktree. The
main checkout's file is read, never copied: a copy would go stale and shadow
later changes made in the main checkout.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Developer name or None if not initialized.
"""
env_name = os.environ.get(ENV_DEVELOPER, "").strip()
if env_name:
return env_name
if repo_root is None:
repo_root = get_repo_root()
local = _read_developer_file(repo_root / DIR_WORKFLOW / FILE_DEVELOPER)
if local:
return local
main_root = main_worktree_root(repo_root)
if main_root is None:
return None
return _read_developer_file(main_root / DIR_WORKFLOW / FILE_DEVELOPER)
def check_developer(repo_root: Path | None = None) -> bool:
"""Check if developer is initialized.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
True if developer is initialized.
"""
return get_developer(repo_root) is not None
# =============================================================================
# Tasks Directory
# =============================================================================
def get_tasks_dir(repo_root: Path | None = None) -> Path:
"""Get tasks directory path.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Path to tasks directory.
"""
if repo_root is None:
repo_root = get_repo_root()
return repo_root / DIR_WORKFLOW / DIR_TASKS
# =============================================================================
# Workspace Directory
# =============================================================================
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
"""Get developer workspace directory.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Path to workspace directory or None if developer not set.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
if developer:
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
return None
# =============================================================================
# Journal File
# =============================================================================
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
"""Get the current active journal file.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Path to active journal file or None if not found.
"""
if repo_root is None:
repo_root = get_repo_root()
workspace_dir = get_workspace_dir(repo_root)
if workspace_dir is None or not workspace_dir.is_dir():
return None
latest: Path | None = None
highest = 0
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
if not f.is_file():
continue
# Extract number from filename
name = f.stem # e.g., "journal-1"
match = re.search(r"(\d+)$", name)
if match:
num = int(match.group(1))
if num > highest:
highest = num
latest = f
return latest
def count_lines(file_path: Path) -> int:
"""Count lines in a file.
Args:
file_path: Path to file.
Returns:
Number of lines, or 0 if file doesn't exist.
"""
if not file_path.is_file():
return 0
try:
return len(file_path.read_text(encoding="utf-8").splitlines())
except (OSError, IOError):
return 0
# =============================================================================
# Current Task Management
# =============================================================================
def normalize_task_ref(task_ref: str) -> str:
"""Normalize a task ref for stable runtime storage.
Stored refs should prefer repo-relative POSIX paths like
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
unless they can later be converted back to repo-relative form by callers.
"""
normalized = task_ref.strip()
if not normalized:
return ""
path_obj = Path(normalized)
if path_obj.is_absolute():
return str(path_obj)
normalized = normalized.replace("\\", "/")
while normalized.startswith("./"):
normalized = normalized[2:]
if normalized.startswith(f"{DIR_TASKS}/"):
return f"{DIR_WORKFLOW}/{normalized}"
return normalized
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
"""Resolve a task ref to an absolute task directory path inside the repo.
Returns None when the ref resolves outside `repo_root`. Every reader of the
active task — `task.py`, the shared hooks, the platform extensions — comes
through here, so containment is enforced at this one point rather than at
each call site.
It matters because a ref is not always something the user typed. It round
trips through the session pointer under `.trellis/.runtime/sessions/`, and
`..` segments used to survive that trip intact: `_canonical_task_ref`
compares lexically, and a lexical `relative_to` accepts
`<root>/.trellis/tasks/../../../elsewhere` because the string does start
with the root. The ref was then stored verbatim and replayed on every later
turn, so `task.py start .trellis/tasks/../../../elsewhere` both rewrote that
directory's `task.json` and fed its files to the model.
Resolving here also normalises the path, so callers get a ref without `..`
to store.
"""
if repo_root is None:
repo_root = get_repo_root()
normalized = normalize_task_ref(task_ref)
if not normalized:
return None
try:
root = repo_root.resolve()
except OSError:
return None
path_obj = Path(normalized)
if path_obj.is_absolute():
candidate = path_obj
elif normalized.startswith(f"{DIR_WORKFLOW}/"):
candidate = root / path_obj
else:
candidate = root / DIR_WORKFLOW / DIR_TASKS / path_obj
# resolve() collapses `..` and follows symlinks, so a task directory that
# links outside the repo is refused too. Both sides are resolved because
# repo_root itself may sit behind a symlink (/tmp on macOS does).
try:
resolved = candidate.resolve()
workflow_real = (root / DIR_WORKFLOW).resolve()
except OSError:
return None
try:
resolved.relative_to(root)
return resolved
except ValueError:
pass
# `.trellis` may itself be a symlink into a store outside the repo (#567).
# The workflow dir's own real location is then a second legitimate
# containment base: a ref through that link never left the workflow tree.
# A ref that escapes BOTH bases (traversal, absolute path elsewhere, a
# task dir symlinked out of the tree) is still refused.
try:
rel = resolved.relative_to(workflow_real)
except ValueError:
return None
# Map back to the in-repo (lexical) form so callers store the same
# repo-relative ref as in the non-symlinked layout.
return root / DIR_WORKFLOW / rel
def get_current_task(
repo_root: Path | None = None,
platform_input: dict | None = None,
platform: str | None = None,
) -> str | None:
"""Get current task directory path (relative to repo_root).
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Relative path to current task directory or None.
"""
if repo_root is None:
repo_root = get_repo_root()
from .active_task import resolve_active_task
return resolve_active_task(repo_root, platform_input, platform).task_path
def get_current_task_abs(
repo_root: Path | None = None,
platform_input: dict | None = None,
platform: str | None = None,
) -> Path | None:
"""Get current task directory absolute path.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Absolute path to current task directory or None.
"""
if repo_root is None:
repo_root = get_repo_root()
relative = get_current_task(repo_root, platform_input, platform)
if relative:
return resolve_task_ref(relative, repo_root)
return None
def get_current_task_source(
repo_root: Path | None = None,
platform_input: dict | None = None,
platform: str | None = None,
) -> tuple[str, str | None, str | None]:
"""Get active task source as (`source`, `context_key`, `task_path`)."""
if repo_root is None:
repo_root = get_repo_root()
from .active_task import get_current_task_source as _get_source
return _get_source(repo_root, platform_input, platform)
def set_current_task(
task_path: str,
repo_root: Path | None = None,
platform_input: dict | None = None,
platform: str | None = None,
) -> bool:
"""Set current task in session scope.
Args:
task_path: Task directory path (relative to repo_root).
repo_root: Repository root path. Defaults to auto-detected.
Returns:
True on success, False on error.
"""
if repo_root is None:
repo_root = get_repo_root()
from .active_task import set_active_task
return set_active_task(
task_path,
repo_root,
platform_input=platform_input,
platform=platform,
) is not None
def clear_current_task(
repo_root: Path | None = None,
platform_input: dict | None = None,
platform: str | None = None,
) -> bool:
"""Clear current task in session scope.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
True on success.
"""
if repo_root is None:
repo_root = get_repo_root()
from .active_task import clear_active_task
clear_active_task(
repo_root,
platform_input=platform_input,
platform=platform,
)
return True
def has_current_task(repo_root: Path | None = None) -> bool:
"""Check if has current task.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
True if current task is set.
"""
return get_current_task(repo_root) is not None
# =============================================================================
# Task ID Generation
# =============================================================================
def generate_task_date_prefix() -> str:
"""Generate task ID based on date (MM-DD format).
Returns:
Date prefix string (e.g., "01-21").
"""
return datetime.now().strftime("%m-%d")
# =============================================================================
# Monorepo / Package Paths
# =============================================================================
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
"""Get the spec directory path.
Single-repo: .trellis/spec
Monorepo with package: .trellis/spec/<package>
Uses lazy import to avoid circular dependency with config.py.
"""
if repo_root is None:
repo_root = get_repo_root()
from .config import get_spec_base
base = get_spec_base(package, repo_root)
return repo_root / DIR_WORKFLOW / base
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
"""Get a package's source directory absolute path from config.
Returns:
Absolute path to the package directory, or None if not found.
"""
if repo_root is None:
repo_root = get_repo_root()
from .config import get_packages
packages = get_packages(repo_root)
if not packages or package not in packages:
return None
info = packages[package]
if isinstance(info, dict):
rel_path = info.get("path", package)
else:
rel_path = str(info)
return repo_root / rel_path
# =============================================================================
# Main Entry (for testing)
# =============================================================================
if __name__ == "__main__":
repo = get_repo_root()
print(f"Repository root: {repo}")
print(f"Developer: {get_developer(repo)}")
print(f"Tasks dir: {get_tasks_dir(repo)}")
print(f"Workspace dir: {get_workspace_dir(repo)}")
print(f"Journal file: {get_active_journal_file(repo)}")
print(f"Current task: {get_current_task(repo)}")
+322
View File
@@ -0,0 +1,322 @@
"""
Safe git-add helpers for Trellis-owned paths.
Why this module exists
----------------------
A real user incident: a project's `.gitignore` listed `.trellis/` (company-wide
template / personal habit). When `add_session.py` and `task.py archive` ran
their auto-commit and `git add` failed with `ignored by .gitignore`, the AI
agent driving the workflow "fixed" it by retrying with
`git add -f .trellis/` — which fan-out-included every ignored subtree
(`.trellis/.backup-*/`, `.trellis/worktrees/`, `.trellis/.template-hashes.json`,
`.trellis/.runtime/`), committing 548 files / 83474 lines of caches/backups.
Design
------
- Scripts only stage SPECIFIC product paths (journal files, index.md, the
current task dir, the archive dir). Never the whole `.trellis/` tree.
- If plain `git add <specific>` fails with "ignored by", DO NOT retry with
``-f``. The presence of `.trellis/` in `.gitignore` is treated as user
intent ("keep .trellis/ local-only"). The script warns and skips the
auto-commit; users who want auto-staging can either fix their `.gitignore`
or set ``session_auto_commit: false`` and manage git themselves.
- The warning includes a negative example: ``Do NOT use `git add -f .trellis/` ...``
so any AI rereading the log doesn't reinvent the bug.
History note: 0.5.10 introduced an automatic ``git add -f`` retry on the
specific paths. That was reverted in 0.5.11 — auto-forcing into a tree the
user had gitignored violates user intent even when the path list is narrow.
The wider-grain forbidden command stays forbidden, and the narrow-grain auto
``-f`` is gone too.
"""
from __future__ import annotations
import sys
from pathlib import Path
from .git import run_git, run_git_retry_index_lock
from .paths import (
DIR_ARCHIVE,
DIR_TASKS,
DIR_WORKFLOW,
DIR_WORKSPACE,
FILE_JOURNAL_PREFIX,
get_developer,
)
# Paths under .trellis/ that must NEVER be auto-staged. Listed here so the
# warning to the user can show concrete subpaths to ignore individually
# instead of ignoring the whole `.trellis/` tree.
TRELLIS_IGNORED_SUBPATHS = (
".trellis/.backup-*",
".trellis/worktrees/",
".trellis/.template-hashes.json",
".trellis/.runtime/",
".trellis/.cache/",
)
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
arguments to git. The caller is responsible for `git diff --cached`
checking afterwards.
Included:
- .trellis/workspace/<developer>/journal-*.md
- .trellis/workspace/<developer>/index.md
- .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] = []
# Workspace journal files + index.md
developer = get_developer(repo_root)
if developer:
ws = repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
if ws.is_dir():
for f in sorted(ws.glob(f"{FILE_JOURNAL_PREFIX}*.md")):
if f.is_file():
paths.append(
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{f.name}"
)
index_md = ws / "index.md"
if index_md.is_file():
paths.append(
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/index.md"
)
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
if not tasks_dir.is_dir():
return paths
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
def safe_archive_paths_to_add(
repo_root: Path,
task_name: str | None = None,
modified_children: list[str] | None = None,
) -> list[str]:
"""Return paths to stage after `task.py archive`.
Scoped to ONLY the paths the archive operation actually touched:
- the archive subtree (where the freshly-moved task lives)
- the source task directory (for source-side deletes; caller pairs
this with `git rm --cached` since `git add` won't stage deletes
for a path that no longer exists in the working tree)
- any child task directories whose `task.json` was edited to drop
the archived parent (parent-children relationship update)
This narrow scope avoids "scope creep" — dirty changes in OTHER
active task dirs (parallel-window edits) are NOT bundled into the
archive commit. Callers handle each kind of change in its own
commit boundary.
Backwards-compat: with no arguments, the function walks the whole
`.trellis/tasks/` subtree the old way (active tasks + archive). New
callers should always pass `task_name`.
"""
paths: list[str] = []
tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
if not tasks_dir.is_dir():
return paths
archive_dir = tasks_dir / DIR_ARCHIVE
if task_name is not None:
# Narrow scope — only paths that still exist on disk (so
# `git add` doesn't choke on the moved-away source). The caller
# handles the source-side deletes via `git rm --cached`
# explicitly.
if archive_dir.is_dir():
paths.append(
f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}"
)
for child_name in modified_children or []:
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}")
return paths
# Legacy wide scope (no task_name): preserve old behavior so callers
# that have not been updated keep working.
if archive_dir.is_dir():
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}")
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}")
return paths
def _stderr_indicates_ignored(stderr: str) -> bool:
"""git add error indicates the path is excluded by .gitignore."""
if not stderr:
return False
lowered = stderr.lower()
return "ignored by" in lowered
def safe_git_add(
paths: list[str], repo_root: Path, retry_on_index_lock: bool = False
) -> tuple[bool, bool, str]:
"""Run `git add` on specific paths; never retry with -f.
Returns ``(success, used_force, stderr)``. The ``used_force`` field is
kept for signature compatibility with the 0.5.10 implementation but is
always ``False`` — we never auto-force.
Behavior:
- No paths passed → success, no force, empty stderr.
- Plain ``git add -- <paths>`` succeeds → return success.
- Plain fails (any reason — ignored or otherwise) → return failure with
the stderr. Callers should inspect the stderr (see
:func:`print_gitignore_warning`) and skip the auto-commit.
``retry_on_index_lock`` opts into the bounded backoff-retry for a held
``.git/index.lock`` (see :func:`~.git.run_git_retry_index_lock`). It is
off by default: only the archive path, which has already moved the task
directory on disk by the time it stages, needs to wait out a transient
lock rather than fail.
"""
if not paths:
return True, False, ""
runner = run_git_retry_index_lock if retry_on_index_lock else run_git
rc, _, err = runner(["add", "--", *paths], cwd=repo_root)
if rc == 0:
return True, False, ""
return False, False, err
def print_gitignore_warning(paths: list[str]) -> None:
"""Explain to the user (and any AI reading the log) what to do.
CRITICAL: includes the negative example
``Do NOT use `git add -f .trellis/``` — agents reading the warning are
known to invent that command, which fans out to ignored caches/backups.
"""
print(
"[WARN] git add failed because .trellis/ paths are ignored by your .gitignore.",
file=sys.stderr,
)
print(
"[WARN] Skipping auto-commit. The journal/task files were still written to disk;",
file=sys.stderr,
)
print(
"[WARN] git was not touched.",
file=sys.stderr,
)
print("[WARN]", file=sys.stderr)
print(
"[WARN] Trellis manages these specific paths and they should be tracked:",
file=sys.stderr,
)
if paths:
for p in paths:
print(f"[WARN] {p}", file=sys.stderr)
else:
print(
"[WARN] .trellis/workspace/<developer>/{journal-*.md,index.md}",
file=sys.stderr,
)
print(
"[WARN] .trellis/tasks/<task-dir>/",
file=sys.stderr,
)
print(
"[WARN] .trellis/tasks/archive/",
file=sys.stderr,
)
print("[WARN]", file=sys.stderr)
print(
"[WARN] Recommended: change your .gitignore from `.trellis/` to specific",
file=sys.stderr,
)
print(
"[WARN] subpaths that should remain ignored, e.g.:",
file=sys.stderr,
)
for sub in TRELLIS_IGNORED_SUBPATHS:
print(f"[WARN] {sub}", file=sys.stderr)
print("[WARN]", file=sys.stderr)
print(
"[WARN] Or, if you intentionally keep .trellis/ local-only, set in",
file=sys.stderr,
)
print(
"[WARN] .trellis/config.yaml:",
file=sys.stderr,
)
print(
"[WARN] session_auto_commit: false",
file=sys.stderr,
)
print(
"[WARN] so the scripts skip git entirely and you can review / commit",
file=sys.stderr,
)
print(
"[WARN] manually with `git status` / `git add` / `git commit`.",
file=sys.stderr,
)
print("[WARN]", file=sys.stderr)
print(
"[WARN] Do NOT use `git add -f .trellis/` — it pulls in backups, worktrees,",
file=sys.stderr,
)
print(
"[WARN] and runtime caches that should never be committed.",
file=sys.stderr,
)
+892
View File
@@ -0,0 +1,892 @@
#!/usr/bin/env python3
"""
Session context generation (default + record modes).
Provides:
get_context_json - JSON output for default mode
get_context_text - Text output for default mode
get_context_record_json - JSON for record mode
get_context_text_record - Text for record mode
output_json - Print JSON
output_text - Print text
get_update_hint - Once-per-session "update available" line
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from .active_task import resolve_context_key
from .config import get_git_packages
from .git import run_git
from .packages_context import get_packages_section
from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
from .paths import (
DIR_SCRIPTS,
DIR_SPEC,
DIR_TASKS,
DIR_WORKFLOW,
DIR_WORKSPACE,
count_lines,
get_active_journal_file,
get_current_task,
get_current_task_source,
get_developer,
get_repo_root,
get_tasks_dir,
)
# =============================================================================
# Helpers
# =============================================================================
_PACKAGE_NAME = "@mindfoldhq/trellis"
_UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
_VERSION_RE = re.compile(
r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
)
_VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
_POLYREPO_IGNORED_DIRS = {
"node_modules",
"target",
"dist",
"build",
"out",
"bin",
"obj",
"vendor",
"coverage",
"tmp",
"__pycache__",
}
_POLYREPO_SCAN_MAX_DEPTH = 2
_POLYREPO_SCAN_MAX_REPOS = 8
_GIT_PROBE_TIMEOUT_SECONDS = 2.0
def _is_git_worktree(path: Path) -> bool:
"""Return True when path is inside a Git worktree."""
rc, out, _ = run_git(
["rev-parse", "--is-inside-work-tree"],
cwd=path,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
return rc == 0 and out.strip().lower() == "true"
def _parse_recent_commits(log_output: str) -> list[dict]:
"""Parse `git log --oneline` output into structured commit entries."""
commits = []
for line in log_output.splitlines():
if not line.strip():
continue
parts = line.split(" ", 1)
if len(parts) >= 2:
commits.append({"hash": parts[0], "message": parts[1]})
elif len(parts) == 1:
commits.append({"hash": parts[0], "message": ""})
return commits
def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
"""Collect Git status for one known repository directory."""
if not (repo_dir / ".git").exists():
return None
status_rc, status_out, _ = run_git(
["status", "--porcelain"],
cwd=repo_dir,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
if status_rc != 0:
return None
changes = len([line for line in status_out.splitlines() if line.strip()])
_, branch_out, _ = run_git(
["branch", "--show-current"],
cwd=repo_dir,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
branch = branch_out.strip() or "unknown"
_, log_out, _ = run_git(
["log", "--oneline", "-5"],
cwd=repo_dir,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
return {
"name": name,
"path": rel_path,
"branch": branch,
"isClean": changes == 0,
"uncommittedChanges": changes,
"recentCommits": _parse_recent_commits(log_out),
}
def _collect_root_git_info(repo_root: Path) -> dict:
"""Collect root Git info without pretending a non-Git root is clean."""
if not _is_git_worktree(repo_root):
return {
"isRepo": False,
"branch": "",
"isClean": False,
"uncommittedChanges": 0,
"recentCommits": [],
}
_, branch_out, _ = run_git(
["branch", "--show-current"],
cwd=repo_root,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
branch = branch_out.strip() or "unknown"
status_rc, status_out, _ = run_git(
["status", "--porcelain"],
cwd=repo_root,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
status_lines = [line for line in status_out.splitlines() if line.strip()]
_, short_out, _ = run_git(
["status", "--short"],
cwd=repo_root,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
_, log_out, _ = run_git(
["log", "--oneline", "-5"],
cwd=repo_root,
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
)
return {
"isRepo": True,
"branch": branch,
"isClean": status_rc == 0 and len(status_lines) == 0,
"uncommittedChanges": len(status_lines),
"statusShort": short_out.splitlines(),
"recentCommits": _parse_recent_commits(log_out),
}
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
"""Discover child Git repositories using the init-time polyrepo heuristic."""
found: list[str] = []
overflow = False
def is_candidate_dir(path: Path) -> bool:
name = path.name
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
def scan(rel_dir: Path, depth: int) -> None:
nonlocal overflow
if overflow:
return
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
return
abs_dir = repo_root / rel_dir
try:
children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
except OSError:
return
for child in children:
if not child.is_dir() or not is_candidate_dir(child):
continue
child_rel = (
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
)
if (child / ".git").exists():
if len(found) >= _POLYREPO_SCAN_MAX_REPOS:
overflow = True
return
found.append(child_rel.as_posix())
continue
scan(child_rel, depth + 1)
scan(Path("."), 0)
if overflow:
print(
"warning: found more than "
f"{_POLYREPO_SCAN_MAX_REPOS} child Git repositories; "
"skipping automatic Git status collection. Configure explicit "
"packages entries with path and git: true in .trellis/config.yaml.",
file=sys.stderr,
)
return []
if len(found) < 2:
return []
return [(path.replace("/", "_"), path) for path in sorted(found)]
def _collect_package_git_info(
repo_root: Path,
discover_unconfigured: bool = False,
) -> list[dict]:
"""Collect Git status for independent package repositories.
Packages marked with ``git: true`` in config.yaml are authoritative.
When the Trellis root is not a Git repo and no configured package repos are
available, optionally fall back to the bounded polyrepo child scan.
Returns:
List of dicts with keys: name, path, branch, isClean,
uncommittedChanges, recentCommits.
Empty list if no git-repo packages are configured.
"""
git_pkgs = get_git_packages(repo_root)
result = []
for pkg_name, pkg_path in git_pkgs.items():
pkg_dir = repo_root / pkg_path
info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
if info is not None:
result.append(info)
if result or not discover_unconfigured:
return result
discovered = []
for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
if info is not None:
discovered.append(info)
return discovered
def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
"""Append root Git status without misleading non-Git roots."""
lines.append("## GIT STATUS")
if not root_git_info["isRepo"]:
lines.append("Root is not a Git repository.")
lines.append("Run Git commands from the package repository paths listed below.")
else:
lines.append(f"Branch: {root_git_info['branch']}")
if root_git_info["isClean"]:
lines.append("Working directory: Clean")
else:
lines.append(
f"Working directory: {root_git_info['uncommittedChanges']} "
"uncommitted change(s)"
)
lines.append("")
lines.append("Changes:")
for line in root_git_info.get("statusShort", [])[:10]:
lines.append(line)
lines.append("")
lines.append("## RECENT COMMITS")
if not root_git_info["isRepo"]:
lines.append(
"Root has no Git commit history because it is not a Git repository."
)
elif root_git_info["recentCommits"]:
for commit in root_git_info["recentCommits"]:
lines.append(f"{commit['hash']} {commit['message']}")
else:
lines.append("(no commits)")
lines.append("")
def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
"""Append Git status and recent commits for package repositories."""
for pkg in package_git_info:
lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
lines.append(f"Branch: {pkg['branch']}")
if pkg["isClean"]:
lines.append("Working directory: Clean")
else:
lines.append(
f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
)
lines.append("")
lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
if pkg["recentCommits"]:
for commit in pkg["recentCommits"]:
lines.append(f"{commit['hash']} {commit['message']}")
else:
lines.append("(no commits)")
lines.append("")
def _read_project_version(repo_root: Path) -> str | None:
try:
version = (repo_root / DIR_WORKFLOW / ".version").read_text(
encoding="utf-8"
).strip()
except OSError:
return None
return version or None
def _fetch_trellis_version_output() -> str | None:
try:
result = subprocess.run(
["trellis", "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
)
except (OSError, subprocess.SubprocessError, TimeoutError):
return None
if result.returncode != 0:
return None
output = f"{result.stdout}\n{result.stderr}".strip()
return output or None
def _extract_available_update_version(output: str) -> str | None:
update_match = re.search(
r"Trellis update available:\s*"
r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
output,
)
if update_match:
return update_match.group("latest").strip()
candidates = _VERSION_TOKEN_RE.findall(output)
return candidates[-1] if candidates else None
def _resolve_available_update_version() -> str | None:
output = _fetch_trellis_version_output()
if not output:
return None
return _extract_available_update_version(output)
def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
match = _VERSION_RE.match(version)
if not match:
return None
major, minor, patch, prerelease = match.groups()
numbers = (int(major), int(minor or "0"), int(patch or "0"))
prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
return numbers, prerelease_parts
def _compare_prerelease(
left: tuple[str, ...] | None,
right: tuple[str, ...] | None,
) -> int:
if left is None and right is None:
return 0
if left is None:
return 1
if right is None:
return -1
for left_part, right_part in zip(left, right):
if left_part == right_part:
continue
left_numeric = left_part.isdigit()
right_numeric = right_part.isdigit()
if left_numeric and right_numeric:
left_int = int(left_part)
right_int = int(right_part)
return (left_int > right_int) - (left_int < right_int)
if left_numeric:
return -1
if right_numeric:
return 1
return (left_part > right_part) - (left_part < right_part)
return (len(left) > len(right)) - (len(left) < len(right))
def _compare_versions(left: str, right: str) -> int | None:
parsed_left = _parse_version(left)
parsed_right = _parse_version(right)
if parsed_left is None or parsed_right is None:
return None
left_numbers, left_prerelease = parsed_left
right_numbers, right_prerelease = parsed_right
if left_numbers != right_numbers:
return (left_numbers > right_numbers) - (left_numbers < right_numbers)
return _compare_prerelease(left_prerelease, right_prerelease)
def _update_marker_path(repo_root: Path, context_key: str | None = None) -> Path:
"""Path of the once-per-session marker that throttles the update check.
`context_key` lets a caller that already resolved session identity pass it
in — the SessionStart hook reads the session id from hook stdin, which is
more reliable than this function's environment-only fallback chain. Shell
entry points leave it None and keep the previous behavior.
"""
if not context_key:
context_key = resolve_context_key()
if not context_key:
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
context_key = terminal_key or f"ppid-{os.getppid()}"
safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
if not safe_key:
safe_key = "session"
return (
repo_root
/ DIR_WORKFLOW
/ ".runtime"
/ f"update-check-{safe_key[:160]}.marker"
)
def _mark_update_check_attempted(
repo_root: Path,
context_key: str | None = None,
) -> bool:
marker_path = _update_marker_path(repo_root, context_key)
if marker_path.exists():
return False
try:
marker_path.parent.mkdir(parents=True, exist_ok=True)
marker_path.write_text("checked\n", encoding="utf-8")
except OSError:
pass
return True
def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
"""Return the "update available" line for this session, at most once.
Public because the SessionStart hook imports it: the text-mode CLI path
(`get_context.py`) used to be the only caller, so hook-driven platforms —
Claude Code included — never saw the reminder at all.
"""
marker_path = _update_marker_path(repo_root, context_key)
if marker_path.exists():
return None
current_version = _read_project_version(repo_root)
if not current_version:
return None
latest_version = _resolve_available_update_version()
if not latest_version:
return None
_mark_update_check_attempted(repo_root, context_key)
comparison = _compare_versions(current_version, latest_version)
if comparison is None or comparison >= 0:
return None
return (
f"Trellis update available: {current_version} -> {latest_version}, "
"run trellis update"
)
# =============================================================================
# JSON Output
# =============================================================================
def get_context_json(repo_root: Path | None = None) -> dict:
"""Get context as a dictionary.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Context dictionary.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
tasks_dir = get_tasks_dir(repo_root)
journal_file = get_active_journal_file(repo_root)
journal_lines = 0
journal_relative = ""
if journal_file and developer:
journal_lines = count_lines(journal_file)
journal_relative = (
f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
)
root_git_info = _collect_root_git_info(repo_root)
# Tasks
tasks = [
{
"dir": t.dir_name,
"name": t.name,
"status": t.status,
"children": list(t.children),
"parent": t.parent,
}
for t in iter_active_tasks(tasks_dir)
]
# Package git repos (independent sub-repositories)
pkg_git_info = _collect_package_git_info(
repo_root,
discover_unconfigured=not root_git_info["isRepo"],
)
result = {
"developer": developer or "",
"git": {
"isRepo": root_git_info["isRepo"],
"branch": root_git_info["branch"],
"isClean": root_git_info["isClean"],
"uncommittedChanges": root_git_info["uncommittedChanges"],
"recentCommits": root_git_info["recentCommits"],
},
"tasks": {
"active": tasks,
"directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
},
"journal": {
"file": journal_relative,
"lines": journal_lines,
"nearLimit": journal_lines > 1800,
},
}
if pkg_git_info:
result["packageGit"] = pkg_git_info
return result
def output_json(repo_root: Path | None = None) -> None:
"""Output context in JSON format.
Args:
repo_root: Repository root path. Defaults to auto-detected.
"""
context = get_context_json(repo_root)
print(json.dumps(context, indent=2, ensure_ascii=False))
# =============================================================================
# Text Output
# =============================================================================
def get_context_text(repo_root: Path | None = None) -> str:
"""Get context as formatted text.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Formatted text output.
"""
if repo_root is None:
repo_root = get_repo_root()
lines = []
lines.append("========================================")
lines.append("SESSION CONTEXT")
lines.append("========================================")
lines.append("")
developer = get_developer(repo_root)
# Developer section
lines.append("## DEVELOPER")
if not developer:
lines.append(
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
)
return "\n".join(lines)
lines.append(f"Name: {developer}")
lines.append("")
root_git_info = _collect_root_git_info(repo_root)
_append_root_git_context(lines, root_git_info)
# Package git repos — independent sub-repositories
_append_package_git_context(
lines,
_collect_package_git_info(
repo_root,
discover_unconfigured=not root_git_info["isRepo"],
),
)
# Current task
lines.append("## CURRENT TASK")
current_task = get_current_task(repo_root)
if current_task:
current_task_dir = repo_root / current_task
source_type, context_key, _ = get_current_task_source(repo_root)
lines.append(f"Path: {current_task}")
lines.append(
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
)
ct = load_task(current_task_dir)
if ct:
lines.append(f"Name: {ct.name}")
lines.append(f"Status: {ct.status}")
lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
if ct.description:
lines.append(f"Description: {ct.description}")
# Check for prd.md
prd_file = current_task_dir / "prd.md"
if prd_file.is_file():
lines.append("")
lines.append("[!] This task has prd.md - read it for task details")
else:
lines.append("(none)")
lines.append("")
# Active tasks
lines.append("## ACTIVE TASKS")
tasks_dir = get_tasks_dir(repo_root)
task_count = 0
# Collect all task data for hierarchy display
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()}
def _print_task_tree(name: str, indent: int = 0) -> None:
nonlocal task_count
t = all_tasks[name]
progress = children_progress(t.children, all_statuses)
prefix = " " * indent
lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
task_count += 1
for child in t.children:
if child in all_tasks:
_print_task_tree(child, indent + 1)
for dir_name in sorted(all_tasks.keys()):
if not all_tasks[dir_name].parent:
_print_task_tree(dir_name)
if task_count == 0:
lines.append("(no active tasks)")
lines.append(f"Total: {task_count} active task(s)")
lines.append("")
# My tasks
lines.append("## MY TASKS (Assigned to me)")
my_task_count = 0
for t in all_tasks.values():
if t.assignee == developer and t.status != "done":
progress = children_progress(t.children, all_statuses)
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
my_task_count += 1
if my_task_count == 0:
lines.append("(no tasks assigned to you)")
lines.append("")
# Journal file
lines.append("## JOURNAL FILE")
journal_file = get_active_journal_file(repo_root)
if journal_file:
journal_lines = count_lines(journal_file)
relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
lines.append(f"Active file: {relative}")
lines.append(f"Line count: {journal_lines} / 2000")
if journal_lines > 1800:
lines.append("[!] WARNING: Approaching 2000 line limit!")
else:
lines.append("No journal file found")
lines.append("")
# Packages
packages_text = get_packages_section(repo_root)
if packages_text:
lines.append(packages_text)
lines.append("")
# Paths
lines.append("## PATHS")
lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
lines.append("")
lines.append("========================================")
return "\n".join(lines)
# =============================================================================
# Record Mode
# =============================================================================
def get_context_record_json(repo_root: Path | None = None) -> dict:
"""Get record-mode context as a dictionary.
Focused on: my active tasks, git status, current task.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
tasks_dir = get_tasks_dir(repo_root)
root_git_info = _collect_root_git_info(repo_root)
# My tasks (single pass — collect statuses and filter by assignee)
all_tasks_list = list(iter_active_tasks(tasks_dir))
all_statuses = {t.dir_name: t.status for t in all_tasks_list}
my_tasks = []
for t in all_tasks_list:
if t.assignee == developer:
done = sum(
1 for c in t.children
if all_statuses.get(c) in ("completed", "done")
)
my_tasks.append({
"dir": t.dir_name,
"title": t.title,
"status": t.status,
"priority": t.priority,
"children": list(t.children),
"childrenDone": done,
"parent": t.parent,
"meta": t.meta,
})
# Current task
current_task_info = None
current_task = get_current_task(repo_root)
if current_task:
source_type, context_key, _ = get_current_task_source(repo_root)
ct = load_task(repo_root / current_task)
if ct:
current_task_info = {
"path": current_task,
"name": ct.name,
"status": ct.status,
"source": source_type,
"contextKey": context_key,
}
# Package git repos
pkg_git_info = _collect_package_git_info(
repo_root,
discover_unconfigured=not root_git_info["isRepo"],
)
result = {
"developer": developer or "",
"git": {
"isRepo": root_git_info["isRepo"],
"branch": root_git_info["branch"],
"isClean": root_git_info["isClean"],
"uncommittedChanges": root_git_info["uncommittedChanges"],
"recentCommits": root_git_info["recentCommits"],
},
"myTasks": my_tasks,
"currentTask": current_task_info,
}
if pkg_git_info:
result["packageGit"] = pkg_git_info
return result
def get_context_text_record(repo_root: Path | None = None) -> str:
"""Get context as formatted text for record-session mode.
Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
then GIT STATUS, RECENT COMMITS, CURRENT TASK.
"""
if repo_root is None:
repo_root = get_repo_root()
lines: list[str] = []
lines.append("========================================")
lines.append("SESSION CONTEXT (RECORD MODE)")
lines.append("========================================")
lines.append("")
developer = get_developer(repo_root)
if not developer:
lines.append(
f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
)
return "\n".join(lines)
# MY ACTIVE TASKS — first and prominent
lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
lines.append("[!] Review whether any should be archived before recording this session.")
lines.append("")
tasks_dir = get_tasks_dir(repo_root)
my_task_count = 0
# Single pass — collect all tasks and filter by assignee
all_statuses = get_all_statuses(tasks_dir)
for t in iter_active_tasks(tasks_dir):
if t.assignee == developer:
progress = children_progress(t.children, all_statuses)
lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
my_task_count += 1
if my_task_count == 0:
lines.append("(no active tasks assigned to you)")
lines.append("")
root_git_info = _collect_root_git_info(repo_root)
_append_root_git_context(lines, root_git_info)
# Package git repos — independent sub-repositories
_append_package_git_context(
lines,
_collect_package_git_info(
repo_root,
discover_unconfigured=not root_git_info["isRepo"],
),
)
# CURRENT TASK
lines.append("## CURRENT TASK")
current_task = get_current_task(repo_root)
if current_task:
source_type, context_key, _ = get_current_task_source(repo_root)
lines.append(f"Path: {current_task}")
lines.append(
f"Source: {source_type}" + (f":{context_key}" if context_key else "")
)
ct = load_task(repo_root / current_task)
if ct:
lines.append(f"Name: {ct.name}")
lines.append(f"Status: {ct.status}")
else:
lines.append("(none)")
lines.append("")
lines.append("========================================")
return "\n".join(lines)
def output_text(repo_root: Path | None = None) -> None:
"""Output context in text format.
Args:
repo_root: Repository root path. Defaults to auto-detected.
"""
if repo_root is None:
repo_root = get_repo_root()
update_hint = get_update_hint(repo_root)
if update_hint:
print(update_hint)
print("")
print(get_context_text(repo_root))
+470
View File
@@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""
Task JSONL context management.
Provides:
cmd_add_context - Add entry to JSONL context file
cmd_validate - Validate JSONL context files
cmd_list_context - List JSONL context entries
Note:
``cmd_init_context`` was removed in v0.5.0-beta.12. JSONL context files
are created empty at ``task.py create`` time; 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.
Older Trellis versions seeded those files with a ``{"_example": ...}``
placeholder row. ``cmd_validate`` now rejects that row so a task cannot
validate locally and then fail PR preflight, which treats it as
unresolved scaffolding.
"""
from __future__ import annotations
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 DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, 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
# =============================================================================
def cmd_add_context(args: argparse.Namespace) -> int:
"""Add entry to JSONL context file."""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if target_dir is None:
return 1
jsonl_name = args.file
path = args.path
reason = args.reason or "Added manually"
if not target_dir or not target_dir.is_dir():
print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
return 1
# The JSONL name is user input joined onto the task dir — keep it a plain
# filename so it cannot create files elsewhere.
if "/" in jsonl_name or "\\" in jsonl_name or jsonl_name in (".", ".."):
print(colored(
f"Error: context file must be a plain name (e.g. implement, check): {jsonl_name}",
Colors.RED,
))
return 1
# Support shorthand
if not jsonl_name.endswith(".jsonl"):
jsonl_name = f"{jsonl_name}.jsonl"
jsonl_file = target_dir / jsonl_name
full_path = repo_root / path
entry_type = "file"
if full_path.is_dir():
entry_type = "directory"
if not path.endswith("/"):
path = f"{path}/"
elif not full_path.is_file():
print(colored(f"Error: Path not found: {path}", Colors.RED))
return 1
# Check if already exists
if jsonl_file.is_file():
content = jsonl_file.read_text(encoding="utf-8")
if f'"{path}"' in content:
print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW))
return 0
# Add entry
entry: dict
if entry_type == "directory":
entry = {"file": path, "type": "directory", "reason": reason}
else:
entry = {"file": path, "reason": reason}
with jsonl_file.open("a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
print(colored(f"Added {entry_type}: {path}", Colors.GREEN))
return 0
# =============================================================================
# Command: validate
# =============================================================================
def curated_entry_count(jsonl_file: Path) -> int | None:
"""Count curated entries in a jsonl context manifest.
Returns None when the file does not exist — `task.py create` seeds the
manifests only on sub-agent-capable platforms, so an absent file means no
sub-agent will ever read it and callers should not gate on it. A curated
entry is a JSON object row carrying a truthy ``file`` (or legacy ``path``)
value: the same rows the sub-agent injection hook materializes.
"""
if not jsonl_file.is_file():
return None
try:
lines = jsonl_file.read_text(encoding="utf-8").splitlines()
except OSError:
return 0
count = 0
for line in lines:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(data, dict) and (data.get("file") or data.get("path")):
count += 1
return count
def cmd_validate(args: argparse.Namespace) -> int:
"""Validate JSONL context files."""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if target_dir is None or not target_dir.is_dir():
print(colored("Error: task directory required", Colors.RED))
return 1
print(colored("=== Validating Context Files ===", Colors.BLUE))
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, target_dir)
total_errors += errors
print()
if total_errors == 0:
print(colored("✓ All validations passed", Colors.GREEN))
return 0
else:
print(colored(f"✗ Validation failed ({total_errors} errors)", Colors.RED))
return 1
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 _resolve_context_entry_path(
file_path: str, repo_root: Path, task_dir: Path | None
) -> Path | None:
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
Exact historical self-references are remapped only for archived tasks.
``None`` means the remapped path traversed or resolved outside that archive.
"""
repo_path = repo_root / file_path
if task_dir is None:
return repo_path
try:
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
except ValueError:
return repo_path
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
return repo_path
year_month = task_parts[3]
if (
len(year_month) != 7
or year_month[4] != "-"
or not year_month[:4].isdigit()
or not year_month[5:].isdigit()
):
return repo_path
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
posix_path = file_path.replace("\\", "/")
if posix_path == historical_root:
relative_parts: tuple[str, ...] = ()
elif posix_path.startswith(f"{historical_root}/"):
relative_path = posix_path[len(historical_root) + 1 :]
if relative_path.endswith("/"):
relative_path = relative_path[:-1]
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
if any(part in ("", ".", "..") for part in relative_parts):
return None
else:
return repo_path
try:
archive_root = task_dir.resolve()
resolved_path = task_dir.joinpath(*relative_parts).resolve()
resolved_path.relative_to(archive_root)
except (OSError, RuntimeError, ValueError):
return None
return resolved_path
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
"""Validate a single JSONL file.
``{"_example": ...}`` placeholder rows written by older Trellis versions
are hard errors: PR preflight rejects them as unresolved scaffolding, so
accepting them here would pass locally and fail later. Other rows without
a ``file`` field are skipped silently, matching what consumers do.
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
if not jsonl_file.is_file():
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():
line_num += 1
if not line.strip():
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}")
errors += 1
continue
if not isinstance(data, dict):
print(
f" {colored(f'{file_name}:{line_num}: Expected a JSON object', Colors.RED)}"
)
errors += 1
continue
if "_example" in data:
error_message = (
f"{file_name}:{line_num}: Placeholder `_example` row left by an older "
"task.py create — delete this line, or replace it with "
'{"file": "<path>", "reason": "<why>"}'
)
print(f" {colored(error_message, Colors.RED)}")
errors += 1
continue
file_path = data.get("file")
entry_type = data.get("type", "file")
if not file_path:
# Comment / unknown row without a path — skip silently
continue
if not isinstance(file_path, str):
# A truthy non-string (e.g. {"file": 1}) reached path joining and
# raised TypeError, so validation crashed on the row it exists to
# report.
print(
f" {colored(f'{file_name}:{line_num}: `file` must be a string path', Colors.RED)}"
)
errors += 1
continue
real_entries += 1
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
if entry_type == "directory":
if full_path is None or not full_path.is_dir():
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
errors += 1
continue
if full_path is None or 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_message = (
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_message, Colors.YELLOW)}")
if max_file_bytes:
# Advisory hygiene warning, so it must never be what fails
# `validate`. `stat()` can still raise after the `is_file()` check
# above — a permission change, or the file removed in between.
try:
size: int | None = full_path.stat().st_size
except OSError:
size = None
if size is not None and size > max_file_bytes:
warning_message = (
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_message, Colors.YELLOW)}")
if errors == 0 and real_entries == 0:
# Seed-only / empty manifest: sub-agents dispatched for this task
# would run with zero spec context (#573). Silent-green here is how
# more than half the tasks in the report ended up uncurated.
action = file_name.split(".", 1)[0]
print(
f" {colored(f'{file_name}: ✗ (0 curated entries — sub-agents would get zero spec context)', Colors.RED)}"
)
print(
f" Curate it: python3 .trellis/scripts/task.py add-context <task> {action} <path> \"<why>\""
)
print(
" Intentionally empty? Bypass at start: task.py start <task> --allow-empty-context"
)
return 1
if errors == 0:
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
else:
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}")
return errors
# =============================================================================
# Command: list-context
# =============================================================================
def cmd_list_context(args: argparse.Namespace) -> int:
"""List JSONL context entries."""
repo_root = get_repo_root()
target_dir = resolve_task_dir(args.dir, repo_root)
if target_dir is None or not target_dir.is_dir():
print(colored("Error: task directory required", Colors.RED))
return 1
print(colored("=== Context Files ===", Colors.BLUE))
print()
for jsonl_name in ["implement.jsonl", "check.jsonl"]:
jsonl_file = target_dir / jsonl_name
if not jsonl_file.is_file():
continue
print(colored(f"[{jsonl_name}]", Colors.CYAN))
count = 0
curated = False
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(data, dict):
continue
file_path = data.get("file")
if not file_path:
# Placeholder / comment row — don't count as a real entry
continue
curated = True
count += 1
entry_type = data.get("type", "file")
reason = data.get("reason", "-")
if entry_type == "directory":
print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}")
else:
print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
print(f" {colored('→', Colors.YELLOW)} {reason}")
if not curated:
print(f" {colored('(no curated entries yet)', Colors.YELLOW)}")
print()
return 0
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Task queue utility functions.
Provides:
list_tasks_by_status - List tasks by status
list_pending_tasks - List tasks with pending status
list_tasks_by_assignee - List tasks by assignee
list_my_tasks - List tasks assigned to current developer
get_task_stats - Get P0/P1/P2/P3 counts
"""
from __future__ import annotations
from pathlib import Path
from .paths import (
get_repo_root,
get_developer,
get_tasks_dir,
)
from .tasks import iter_active_tasks
# =============================================================================
# Internal helper
# =============================================================================
def _task_to_dict(t) -> dict:
"""Convert TaskInfo to the dict format callers expect."""
return {
"priority": t.priority,
"id": t.raw.get("id", ""),
"title": t.title,
"status": t.status,
"assignee": t.assignee or "-",
"dir": t.dir_name,
"children": list(t.children),
"parent": t.parent,
}
# =============================================================================
# Public Functions
# =============================================================================
def list_tasks_by_status(
filter_status: str | None = None,
repo_root: Path | None = None
) -> list[dict]:
"""List tasks by status.
Args:
filter_status: Optional status filter.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
List of task info dicts with keys: priority, id, title, status, assignee.
"""
if repo_root is None:
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
results = []
for t in iter_active_tasks(tasks_dir):
if filter_status and t.status != filter_status:
continue
results.append(_task_to_dict(t))
return results
def list_pending_tasks(repo_root: Path | None = None) -> list[dict]:
"""List pending tasks.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
List of task info dicts.
"""
return list_tasks_by_status("planning", repo_root)
def list_tasks_by_assignee(
assignee: str,
filter_status: str | None = None,
repo_root: Path | None = None
) -> list[dict]:
"""List tasks assigned to a specific developer.
Args:
assignee: Developer name.
filter_status: Optional status filter.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
List of task info dicts.
"""
if repo_root is None:
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
results = []
for t in iter_active_tasks(tasks_dir):
if (t.assignee or "-") != assignee:
continue
if filter_status and t.status != filter_status:
continue
results.append(_task_to_dict(t))
return results
def list_my_tasks(
filter_status: str | None = None,
repo_root: Path | None = None
) -> list[dict]:
"""List tasks assigned to current developer.
Args:
filter_status: Optional status filter.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
List of task info dicts.
Raises:
ValueError: If developer not set.
"""
if repo_root is None:
repo_root = get_repo_root()
developer = get_developer(repo_root)
if not developer:
raise ValueError("Developer not set")
return list_tasks_by_assignee(developer, filter_status, repo_root)
def get_task_stats(repo_root: Path | None = None) -> dict[str, int]:
"""Get task statistics.
Args:
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Dict with keys: P0, P1, P2, P3, Total.
"""
if repo_root is None:
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
stats = {"P0": 0, "P1": 0, "P2": 0, "P3": 0, "Total": 0}
for t in iter_active_tasks(tasks_dir):
if t.priority in stats:
stats[t.priority] += 1
stats["Total"] += 1
return stats
def format_task_stats(stats: dict[str, int]) -> str:
"""Format task stats as string.
Args:
stats: Stats dict from get_task_stats.
Returns:
Formatted string like "P0:0 P1:1 P2:2 P3:0 Total:3".
"""
return f"P0:{stats['P0']} P1:{stats['P1']} P2:{stats['P2']} P3:{stats['P3']} Total:{stats['Total']}"
# =============================================================================
# Main Entry (for testing)
# =============================================================================
if __name__ == "__main__":
stats = get_task_stats()
print(format_task_stats(stats))
print()
print("Pending tasks:")
for task in list_pending_tasks():
print(f" {task['priority']}|{task['id']}|{task['title']}|{task['status']}|{task['assignee']}")
+1874
View File
File diff suppressed because it is too large. Load diff
+498
View File
@@ -0,0 +1,498 @@
#!/usr/bin/env python3
"""
Task utility functions.
Provides:
is_within_tasks_dir - Check a resolved path is a task directly under tasks/
find_task_by_name - Find task directory by name
resolve_task_dir - Resolve task directory from name, relative, or absolute path
archive_destination_for - Path a task would be archived to
archive_task_dir - Archive task to monthly directory
run_task_hooks - Run lifecycle hooks for task events
"""
from __future__ import annotations
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from .paths import get_repo_root, get_tasks_dir
if TYPE_CHECKING:
import subprocess
# =============================================================================
# Path Safety
# =============================================================================
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.
Narrows ``resolve_task_dir``'s containment for archive: that chokepoint
accepts anything under the tasks dir, including a task already in
``archive/<YYYY-MM>/``. This rejects those, plus the tasks dir itself, so
``shutil.move`` never re-archives an archived task into a nested copy.
"""
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
# =============================================================================
def find_task_by_name(task_name: str, tasks_dir: Path) -> Path | None:
"""Find task directory by name (exact or suffix match).
A task name is a single directory name under ``tasks_dir``, never a path:
names carrying a separator or a dot segment are rejected before the join,
so ``".."`` cannot hand back the tasks dir's own parent.
An ambiguous suffix (two tasks created on different days with the same
slug) is a failure, not a coin flip — ``iterdir()`` order is filesystem
order, so silently picking the first match picks a different task on a
different machine.
Args:
task_name: Task name to find.
tasks_dir: Tasks directory path.
Returns:
Absolute path to task directory, or None if not found or ambiguous.
"""
if not task_name or not tasks_dir or not tasks_dir.is_dir():
return None
if "/" in task_name or "\\" in task_name or task_name in (".", ".."):
print(f"Error: invalid task name: {task_name}", file=sys.stderr)
return None
# Try exact match first
exact_match = tasks_dir / task_name
if exact_match.is_dir():
return exact_match
# Try suffix match (e.g., "my-task" matches "01-21-my-task")
matches = sorted(
d for d in tasks_dir.iterdir()
if d.is_dir() and d.name.endswith(f"-{task_name}")
)
if len(matches) == 1:
return matches[0]
if matches:
print(f"Error: ambiguous task name '{task_name}' matches:", file=sys.stderr)
for match in matches:
print(f" - {match.name}", file=sys.stderr)
print("Pass the full task directory name.", file=sys.stderr)
return None
# =============================================================================
# Archive Operations
# =============================================================================
def archive_destination_for(task_dir_abs: Path) -> Path:
"""Path a task would be archived to: <tasks>/archive/<YYYY-MM>/<name>."""
tasks_dir = task_dir_abs.parent
year_month = datetime.now().strftime("%Y-%m")
return tasks_dir / "archive" / year_month / task_dir_abs.name
def archive_task_dir(task_dir_abs: Path, repo_root: Path | None = None) -> Path | None:
"""Archive a task directory to archive/{YYYY-MM}/.
Args:
task_dir_abs: Absolute path to task directory.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Path to archived directory, or None on error.
"""
if not task_dir_abs.is_dir():
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
return None
dest = archive_destination_for(task_dir_abs)
month_dir = dest.parent
# Create archive directory
try:
month_dir.mkdir(parents=True, exist_ok=True)
except (OSError, IOError) as e:
print(f"Error: Failed to create archive directory: {e}", file=sys.stderr)
return None
# shutil.move into an existing directory moves the source *inside* it,
# producing archive/<month>/<task>/<task>/ and returning a path that is not
# where the task actually landed. That wrong path then flows into the
# printed result, the after_archive hook's TASK_JSON_PATH, and auto-commit
# staging, so refuse before the move instead.
if dest.exists():
print(
f"Error: refusing to archive {task_dir_abs}: "
f"archive destination already exists: {dest}",
file=sys.stderr,
)
print(
"Move or rename the existing archived task, then retry.",
file=sys.stderr,
)
return None
try:
shutil.move(str(task_dir_abs), str(dest))
except (OSError, IOError, shutil.Error) as e:
print(f"Error: Failed to move task to archive: {e}", file=sys.stderr)
return None
return dest
def archive_task_complete(
task_dir_abs: Path,
repo_root: Path | None = None
) -> dict[str, str]:
"""Complete archive workflow: archive directory.
Args:
task_dir_abs: Absolute path to task directory.
repo_root: Repository root path. Defaults to auto-detected.
Returns:
Dict with archive result info.
"""
if not task_dir_abs.is_dir():
print(f"Error: task directory not found: {task_dir_abs}", file=sys.stderr)
return {}
archive_dest = archive_task_dir(task_dir_abs, repo_root)
if archive_dest:
return {"archived_to": str(archive_dest)}
return {}
# =============================================================================
# Task Directory Resolution
# =============================================================================
def resolve_task_dir(target_dir: str, repo_root: Path) -> Path | None:
"""Resolve task directory to an absolute path inside the tasks directory.
Supports:
- Absolute path: /path/to/task
- Relative path: .trellis/tasks/01-31-my-task
- Task name: my-task (uses find_task_by_name for lookup)
This is the containment chokepoint for every command that accepts a task
directory argument. The candidate is resolved (following symlinks) and must
land strictly under ``.trellis/tasks/``; archived tasks under
``archive/<YYYY-MM>/`` qualify. Traversal (``../victim``), absolute paths
outside the repo, a task dir symlinked out of the tasks tree, and the
tasks directory itself are all rejected here so no caller has to re-check.
The containment base is the tasks dir's own real location, so a
``.trellis`` that is itself a symlink into a shared store (#567) works.
Args:
target_dir: Task directory specification.
repo_root: Repository root path.
Returns:
Absolute path spelled through the repo's own tasks dir (in-repo
lexical form), or None when it is not a location inside the tasks
directory (an error naming the path is printed to stderr).
"""
if not target_dir:
print("Error: task directory is required", file=sys.stderr)
return None
normalized = target_dir.replace("\\", "/")
while normalized.startswith("./"):
normalized = normalized[2:]
tasks_dir = get_tasks_dir(repo_root)
if Path(target_dir).is_absolute():
candidate = Path(target_dir)
elif "/" in normalized or normalized.startswith(".trellis"):
# Relative path (contains path separator or starts with .trellis)
candidate = repo_root / Path(normalized)
else:
# Task name - must resolve inside the tasks directory. The historical
# fallback to repo_root/<name> only ever produced a path the check
# below rejects, so a miss ends here instead.
candidate = find_task_by_name(target_dir, tasks_dir)
if candidate is None:
# find_task_by_name reports invalid names and ambiguity itself.
print(
f"Error: could not resolve task '{target_dir}' under {tasks_dir}",
file=sys.stderr,
)
return None
try:
resolved = candidate.resolve()
tasks_lexical = get_tasks_dir(repo_root.resolve())
tasks_resolved = tasks_lexical.resolve()
except (OSError, RuntimeError) as e:
print(f"Error: could not resolve task directory '{target_dir}': {e}", file=sys.stderr)
return None
if resolved == tasks_resolved:
print(
f"Error: refusing to use '{target_dir}': {tasks_resolved} is the tasks "
"directory itself, not a task",
file=sys.stderr,
)
return None
if tasks_resolved not in resolved.parents:
print(
f"Error: refusing to use '{target_dir}': {resolved} is outside {tasks_resolved}",
file=sys.stderr,
)
return None
# Hand back the path spelled through the repo's own tasks dir rather than
# `resolved`: `.trellis` may be a symlink into a store outside the repo
# (#567), in which case `resolved` sits outside the repo even though the
# task is legitimate, and callers converting to a repo-relative ref for
# storage would refuse it.
return tasks_lexical / resolved.relative_to(tasks_resolved)
# =============================================================================
# Lifecycle Hooks
# =============================================================================
HOOK_TIMEOUT_SECONDS = 60
HOOK_KILL_GRACE_SECONDS = 5
HOOK_OUTPUT_LIMIT = 2000
def _kill_hook_tree(proc: subprocess.Popen[str]) -> None:
"""Kill a timed-out hook's whole process tree, not just the shell.
With ``shell=True`` the direct child is the shell; the actual work runs in
its children. Killing only the shell leaves grandchildren alive holding the
inherited stdout/stderr pipes, and collecting output afterwards then blocks
until those orphans exit — the exact "command that never returns" the
timeout exists to prevent.
POSIX: the hook is started with ``start_new_session=True``, so the shell and
every descendant share one fresh process group; SIGKILL the group.
Windows: ``taskkill /F /T`` walks the tree (best effort).
Either way ``proc.kill()`` is the fallback.
Limitation: a hook that calls ``setsid`` itself leaves the group and
survives this. Accepted and out of scope — a hook is arbitrary code, and the
timeout is a liveness guarantee, not a containment boundary.
"""
import os
import signal
import subprocess
if os.name == "posix":
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
return
except (ProcessLookupError, PermissionError, OSError):
pass
else:
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True,
)
except (OSError, ValueError):
pass
try:
proc.kill()
except OSError:
pass
def _release_hook_process(proc: subprocess.Popen[str]) -> None:
"""Close our pipe ends and reap a finished hook under a bound.
``Popen`` used as a context manager calls ``wait()`` with no timeout when
the block exits. That is the one place the liveness guarantee could still
be lost: if ``_kill_hook_tree`` failed outright — both the process-group
kill and the direct kill raising — the lifecycle command would block there
forever, which is the exact hang the timeout exists to prevent.
"""
import subprocess
for stream in (proc.stdout, proc.stderr, proc.stdin):
if stream is not None:
try:
stream.close()
except OSError:
pass
try:
proc.wait(timeout=HOOK_KILL_GRACE_SECONDS)
except (subprocess.TimeoutExpired, OSError, ValueError):
# Leaves a zombie until this process exits. Fail-open and bounded beats
# a task command that never returns.
pass
def _decode_hook_output(raw: object) -> str:
"""TimeoutExpired carries bytes or str depending on the platform."""
if raw is None:
return ""
if isinstance(raw, bytes):
return raw.decode("utf-8", errors="replace")
return str(raw)
def _print_hook_stream(name: str, text: str) -> None:
"""Print a captured hook stream to stderr, truncated."""
text = text.strip()
if not text:
return
if len(text) > HOOK_OUTPUT_LIMIT:
text = (
text[:HOOK_OUTPUT_LIMIT]
+ f"\n… ({len(text) - HOOK_OUTPUT_LIMIT} more characters truncated)"
)
print(f" {name}:", file=sys.stderr)
for line in text.splitlines():
print(f" {line}", file=sys.stderr)
def run_task_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
"""Run lifecycle hooks for a task event.
Hooks are shell commands read from ``.trellis/config.yaml`` and executed
with ``shell=True`` from the repo root — see the trust boundary note in
``.trellis/spec/cli/backend/script-conventions.md``.
Fail-open by design: a broken hook warns and the lifecycle command
continues. The warning names the event, command, exit status, and both
captured streams, because a hook whose only symptom is "nothing happened"
is undebuggable. A hook that hangs is bounded by
``HOOK_TIMEOUT_SECONDS``; output is captured, so without the timeout the
user sees a task command that never returns and prints nothing. On timeout
the hook's entire process tree is killed (see ``_kill_hook_tree``) and
output is collected under a bounded grace, so a surviving grandchild
holding the pipes cannot re-create that same hang. Cleanup is bounded for
the same reason — see ``_release_hook_process``.
Args:
event: Event name (e.g. "after_create").
task_json_path: Absolute path to the task's task.json.
repo_root: Repository root for cwd and config lookup.
"""
import os
import subprocess
from .config import get_hooks
from .log import Colors, colored
commands = get_hooks(event, repo_root)
if not commands:
return
env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
# POSIX only: a fresh session puts the shell and every descendant in one
# process group, which is what makes the timeout kill the whole tree.
popen_kwargs: dict = {}
if os.name == "posix":
popen_kwargs["start_new_session"] = True
for cmd in commands:
proc = None
try:
proc = subprocess.Popen(
cmd,
shell=True,
cwd=repo_root,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
**popen_kwargs,
)
try:
stdout, stderr = proc.communicate(timeout=HOOK_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired as e:
_kill_hook_tree(proc)
stdout = _decode_hook_output(e.stdout)
stderr = _decode_hook_output(e.stderr)
try:
# Bounded: an orphan that escaped the kill still holds
# the pipes, and waiting on it forever here would be
# the very hang the timeout prevents.
rest_out, rest_err = proc.communicate(
timeout=HOOK_KILL_GRACE_SECONDS
)
stdout = rest_out or stdout
stderr = rest_err or stderr
except (subprocess.TimeoutExpired, OSError, ValueError):
pass
print(
colored(
f"[WARN] Hook timed out ({event}) after "
f"{HOOK_TIMEOUT_SECONDS}s: {cmd}",
Colors.YELLOW,
),
file=sys.stderr,
)
print(f" cwd: {repo_root}", file=sys.stderr)
_print_hook_stream("stdout", stdout)
_print_hook_stream("stderr", stderr)
continue
if proc.returncode != 0:
print(
colored(
f"[WARN] Hook failed ({event}): exit {proc.returncode}: {cmd}",
Colors.YELLOW,
),
file=sys.stderr,
)
print(f" cwd: {repo_root}", file=sys.stderr)
_print_hook_stream("stdout", stdout or "")
_print_hook_stream("stderr", stderr or "")
except Exception as e:
print(
colored(
f"[WARN] Hook error ({event}): {cmd} — {type(e).__name__}: {e}",
Colors.YELLOW,
),
file=sys.stderr,
)
# =============================================================================
# Main Entry (for testing)
# =============================================================================
if __name__ == "__main__":
repo = get_repo_root()
tasks = get_tasks_dir(repo)
print(f"Tasks dir: {tasks}")
print(f"resolve_task_dir('.trellis/tasks/test'): {resolve_task_dir('.trellis/tasks/test', repo)}")
print(f"resolve_task_dir('../test'): {resolve_task_dir('../test', repo)}")
+122
View File
@@ -0,0 +1,122 @@
"""
Task data access layer.
Single source of truth for loading and iterating task directories.
Replaces scattered task.json parsing across 9+ files.
Provides:
load_task — Load a single task by directory path
iter_active_tasks — Iterate all non-archived tasks (sorted)
get_all_statuses — Get {dir_name: status} map for children progress
"""
from __future__ import annotations
import sys
from collections.abc import Iterator
from pathlib import Path
from .io import describe_json_read_failure, read_json_checked
from .paths import FILE_TASK_JSON
from .types import TaskInfo
def load_task(task_dir: Path) -> TaskInfo | None:
"""Load task from a directory containing task.json.
Args:
task_dir: Absolute path to the task directory.
Returns:
TaskInfo if task.json exists and is valid, None otherwise.
A directory without task.json is not a task, so it is skipped silently.
A task.json that exists but cannot be loaded is different: the task
disappears from `task.py list` and from every context the iterator feeds.
Callers stay tolerant, but the skip is announced on stderr so a task
cannot vanish from the workflow with no diagnostic anywhere.
"""
task_json = task_dir / FILE_TASK_JSON
if not task_json.is_file():
return None
data, reason = read_json_checked(task_json)
if data is None:
problem, hint = describe_json_read_failure(task_json, reason)
print(f"[WARN] Skipping task '{task_dir.name}': {problem}", file=sys.stderr)
print(f" {hint}", file=sys.stderr)
return None
return TaskInfo(
dir_name=task_dir.name,
directory=task_dir,
title=data.get("title") or data.get("name") or "unknown",
status=data.get("status", "unknown"),
assignee=data.get("assignee", ""),
priority=data.get("priority", "P2"),
children=tuple(data.get("children", [])),
parent=data.get("parent"),
package=data.get("package"),
raw=data,
)
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
"""Iterate all active (non-archived) tasks, sorted by directory name.
Skips the "archive" directory and directories without valid task.json.
Args:
tasks_dir: Path to the tasks directory.
Yields:
TaskInfo for each valid task.
"""
if not tasks_dir.is_dir():
return
for d in sorted(tasks_dir.iterdir()):
if not d.is_dir() or d.name == "archive":
continue
info = load_task(d)
if info is not None:
yield info
def get_all_statuses(tasks_dir: Path) -> dict[str, str]:
"""Get a {dir_name: status} mapping for all active tasks.
Useful for computing children progress without loading full TaskInfo.
Args:
tasks_dir: Path to the tasks directory.
Returns:
Dict mapping directory names to status strings.
"""
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)}
def children_progress(
children: tuple[str, ...] | list[str],
all_statuses: dict[str, str],
) -> str:
"""Format children progress string like " [2/3 done]".
Args:
children: List of child directory names.
all_statuses: Status map from get_all_statuses().
Returns:
Formatted string, or "" if no children.
"""
if not children:
return ""
# A child missing from active statuses has been archived (cmd_archive
# sets status=completed before moving the dir). Count it as done so
# parent progress doesn't regress when children are archived.
done = sum(
1 for c in children
if c not in all_statuses or all_statuses.get(c) in ("completed", "done")
)
return f" [{done}/{len(children)} done]"
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
Standalone reader for .trellis/config.yaml.
Owns the minimal YAML parser used across Trellis. ``common.config`` imports
``parse_simple_yaml`` from here rather than keeping its own copy: this module
imports nothing from the package, so hooks can load it as a single file, and
one parser cannot drift from another. Returns an empty dict on
missing/malformed files so callers stay simple.
Supported subset: ``key: value`` scalars (everything is a string), nested
mappings by indentation, ``- `` lists of scalars, ``#`` comments (whole-line
and inline outside quotes), and one layer of matching surrounding quotes.
Constructs outside that subset — block scalars, anchors, aliases, merge keys,
flow collections, and mappings nested inside a list — are reported on stderr
and skipped rather than parsed into a plausible-looking wrong value.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
CONFIG_REL_PATH = ".trellis/config.yaml"
def _unquote(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
return value[1:-1]
return value
def _strip_inline_comment(value: str) -> str:
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
is part of the value. Quoted strings are immune.
"""
in_quote: str | None = None
for idx, ch in enumerate(value):
if in_quote:
if ch == in_quote:
in_quote = None
continue
if ch in ('"', "'"):
in_quote = ch
continue
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
return value[:idx]
return value
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
i = start
while i < len(lines):
stripped = lines[i].strip()
if stripped and not stripped.startswith("#"):
return i, lines[i]
i += 1
return i, ""
def _warn_unsupported(source: str, lineno: int, line: str, reason: str) -> None:
"""Report a YAML construct this parser cannot represent, and move on."""
print(
f"[WARN] {source}:{lineno}: {reason}; ignoring: {line.strip()}",
file=sys.stderr,
)
def _is_block_scalar(value: str) -> bool:
"""True for ``|``, ``>`` and their chomping/indent indicators (``|-``, ``>2``)."""
if not value or value[0] not in ("|", ">"):
return False
return all(ch in "+-0123456789" for ch in value[1:])
def _unsupported_value(key: str, value: str) -> str | None:
"""Name the unsupported construct in an unquoted scalar value, else None.
Only unquoted values are inspected: ``cmd: "[a] | b"`` is a string the user
wrote deliberately, while a bare ``notes: |`` or ``base: *anchor`` would
otherwise be stored as the literal marker with the real content dropped.
"""
if key == "<<":
return "YAML merge keys are not supported"
if _is_block_scalar(value):
return "block scalars are not supported"
if value.startswith("&"):
return "YAML anchors are not supported"
if value.startswith("*"):
return "YAML aliases are not supported"
if value.startswith("["):
return "flow sequences are not supported (use `- ` list items)"
if value.startswith("{"):
return "flow mappings are not supported (use an indented mapping)"
return None
def _skip_indented_body(lines: list[str], start: int, indent: int) -> int:
"""Skip the continuation lines of a rejected key (block scalar body etc.)."""
i = start
while i < len(lines):
stripped = lines[i].strip()
if stripped and len(lines[i]) - len(lines[i].lstrip()) <= indent:
break
i += 1
return i
def _parse_yaml_block(
lines: list[str], start: int, min_indent: int, target: dict, source: str
) -> int:
i = start
current_list: list | None = None
# Indent of the key that opened current_list, so a deeper `key: value`
# can be recognized as a mapping inside that list.
list_owner_indent = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped or stripped.startswith("#"):
i += 1
continue
indent = len(line) - len(line.lstrip())
if indent < min_indent:
break
if stripped.startswith("- "):
if current_list is not None:
current_list.append(_unquote(stripped[2:].strip()))
i += 1
elif ":" in stripped:
if current_list is not None and indent > list_owner_indent:
# `- name: cli` / ` path: x`: the second key belongs to a
# mapping inside the list. Storing it would hoist it into the
# parent dict as a sibling of the list — a nested key silently
# becoming a root key.
_warn_unsupported(
source,
i + 1,
line,
"mappings inside a list are not supported",
)
i += 1
continue
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 ('"', "'")
if not was_quoted:
reason = _unsupported_value(key, value)
if reason is not None:
_warn_unsupported(source, i + 1, line, reason)
current_list = None
i = _skip_indented_body(lines, i + 1, indent)
continue
value = _unquote(value)
current_list = None
if value or was_quoted:
target[key] = value
i += 1
else:
next_i, next_line = _next_content_line(lines, i + 1)
if next_i >= len(lines):
target[key] = {}
i = next_i
elif next_line.strip().startswith("- "):
current_list = []
list_owner_indent = indent
target[key] = current_list
i += 1
else:
next_indent = len(next_line) - len(next_line.lstrip())
if next_indent > indent:
nested: dict = {}
target[key] = nested
i = _parse_yaml_block(lines, i + 1, next_indent, nested, source)
else:
target[key] = {}
i += 1
else:
i += 1
return i
def parse_simple_yaml(content: str, source: str = "config.yaml") -> dict:
"""Parse simple YAML with nested dict support (no dependencies).
Supports:
- key: value (string)
- key: (followed by list items)
- item1
- item2
- key: (followed by nested dict)
nested_key: value
nested_key2:
- item
Uses indentation to detect nesting (2+ spaces deeper = child). Every value
is a string; consumers coerce. Unsupported constructs are reported on
stderr against ``source`` and skipped — see the module docstring.
Args:
content: YAML content string.
source: Label used in warnings, normally the config file path.
Returns:
Parsed dict (values can be str, list[str], or dict).
"""
lines = content.splitlines()
result: dict = {}
_parse_yaml_block(lines, 0, 0, result, source)
return result
def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
"""Read .trellis/config.yaml. Returns {} on missing or malformed file."""
root = repo_root or Path.cwd()
config_file = root / CONFIG_REL_PATH
try:
content = config_file.read_text(encoding="utf-8")
except (FileNotFoundError, OSError):
return {}
try:
parsed = parse_simple_yaml(content, source=str(config_file))
except Exception:
return {}
return parsed if isinstance(parsed, dict) else {}
+110
View File
@@ -0,0 +1,110 @@
"""
Core type definitions for Trellis task data.
Provides:
TaskData — TypedDict for task.json shape (read-path type hints only)
TaskInfo — Frozen dataclass for loaded task (the public API type)
AgentRecord — TypedDict for registry.json agent entries
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
# =============================================================================
# task.json shape (TypedDict — used only for read-path type hints)
# =============================================================================
class TaskData(TypedDict, total=False):
"""Shape of task.json on disk.
Used only for type annotations when reading task.json.
Writes must use the original dict to avoid losing unknown fields.
"""
id: str
name: str
title: str
description: str
status: str
dev_type: str
scope: str | None
package: str | None
priority: str
creator: str
assignee: str
createdAt: str
completedAt: str | None
branch: str | None
base_branch: str | None
worktree_path: str | None
commit: str | None
pr_url: str | None
subtasks: list[str]
children: list[str]
parent: str | None
relatedFiles: list[str]
notes: str
meta: dict
# =============================================================================
# Loaded task object (frozen dataclass — the public API type)
# =============================================================================
@dataclass(frozen=True)
class TaskInfo:
"""Immutable view of a loaded task.
Created by load_task() / iter_active_tasks().
Contains the commonly accessed fields; the original dict
is preserved in `raw` for write-back and uncommon field access.
"""
dir_name: str
directory: Path
title: str
status: str
assignee: str
priority: str
children: tuple[str, ...]
parent: str | None
package: str | None
raw: dict # original dict — use for writes and uncommon fields
@property
def name(self) -> str:
"""Task name (id or name field)."""
return self.raw.get("name") or self.raw.get("id") or self.dir_name
@property
def description(self) -> str:
return self.raw.get("description", "")
@property
def branch(self) -> str | None:
return self.raw.get("branch")
@property
def meta(self) -> dict:
return self.raw.get("meta", {})
# =============================================================================
# registry.json agent entry
# =============================================================================
class AgentRecord(TypedDict, total=False):
"""Shape of an agent entry in registry.json."""
id: str
pid: int
task_dir: str
worktree_path: str
branch: str
platform: str
started_at: str
status: str
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Workflow Phase Extraction.
Extracts step-level content from .trellis/workflow.md and optionally filters
platform-specific blocks.
Platform marker syntax in workflow.md:
[Claude Code, Cursor, ...]
agent-capable content
[/Claude Code, Cursor, ...]
Provides:
get_phase_index - Extract the Phase Index section (no --step)
get_step - Extract a single step (#### X.X) section
filter_platform - Strip platform blocks that don't include the given name
"""
from __future__ import annotations
import re
from .paths import DIR_WORKFLOW, get_repo_root
def _workflow_md_path():
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
# Step heading: "#### 1.0 Title" or "#### 1.0 ..."
_STEP_HEADING_RE = re.compile(r"^####\s+(\d+\.\d+)\b.*$")
# Phase Index starts here; Phase 1/2/3 step bodies follow; ends at Breadcrumbs.
_PHASE_INDEX_HEADING = "## Phase Index"
def _read_workflow() -> str:
path = _workflow_md_path()
if not path.exists():
raise FileNotFoundError(f"workflow.md not found: {path}")
return path.read_text(encoding="utf-8")
def _parse_marker(line: str) -> tuple[bool, list[str]] | None:
"""Parse a platform marker line.
Returns:
(is_closing, [platform_names]) if line is a marker, else None.
"""
m = _MARKER_RE.match(line)
if not m:
return None
is_closing = m.group(1) == "/"
names = [p.strip() for p in m.group(2).split(",") if p.strip()]
return is_closing, names
def get_phase_index() -> str:
"""Return the compact Phase Index summary from workflow.md.
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()
start: int | None = None
end: int | None = None
for i, line in enumerate(lines):
stripped = line.strip()
if start is None and stripped == _PHASE_INDEX_HEADING:
start = i
continue
if start is not None and stripped == "## Phase 1: Plan":
end = i
break
if start is None:
return ""
if end is None:
end = len(lines)
section = "\n".join(lines[start:end]).rstrip()
# Strip [workflow-state:STATUS]...[/workflow-state:STATUS] blocks since
# they're injected separately by inject-workflow-state.py per-turn.
import re as _re
tag_re = _re.compile(
r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]\n?",
_re.DOTALL,
)
return tag_re.sub("", section).rstrip() + "\n"
def get_step(step_id: str) -> str:
"""Return the `#### X.X` section matching step_id (header + body).
Body ends at the next `####` or `---` or `##` heading (whichever comes first).
"""
text = _read_workflow()
lines = text.splitlines()
start: int | None = None
for i, line in enumerate(lines):
m = _STEP_HEADING_RE.match(line)
if m and m.group(1) == step_id:
start = i
break
if start is None:
return ""
end: int = len(lines)
for j in range(start + 1, len(lines)):
line = lines[j]
if line.startswith("#### "):
end = j
break
if line.startswith("## "):
end = j
break
# Horizontal rule at column 0
if line.strip() == "---":
end = j
break
return "\n".join(lines[start:end]).rstrip() + "\n"
def _platform_matches(platform: str, block_names: list[str]) -> bool:
"""Case-insensitive fuzzy match: accept 'cursor', 'Cursor', 'claude-code', 'Claude Code'."""
needle = platform.lower().replace("-", "").replace("_", "").replace(" ", "")
for name in block_names:
hay = name.lower().replace("-", "").replace("_", "").replace(" ", "")
if needle == hay:
return True
return False
_PLATFORM_MARKER_LABELS: dict[str, str] = {
# workflow.md marker blocks label platforms with their product names, but
# every caller passes the stable id instead (`--platform {{CLI_FLAG}}` in
# the start / continue commands). `_platform_matches` only strips
# punctuation, so an id that is not its label-minus-spaces never matches and
# `filter_platform` drops the block WITHOUT error — the section just comes
# back empty. Four platforms shipped that way before this table existed.
#
# Add an entry whenever a platform's id is not its marker label with the
# separators removed. `test/registry-invariants.test.ts` asserts every
# registry id keeps a non-empty routing section, so a missing entry fails
# there rather than silently blanking that platform's routing.
"claude": "Claude Code",
"kimi": "Kimi Code",
"omp": "Oh My Pi",
"dsh": "DeepSeek Harness",
}
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-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, Devin]``).
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.
Platforms whose marker label differs from their id resolve through
``_PLATFORM_MARKER_LABELS``. Everything else is returned unchanged.
"""
label = _PLATFORM_MARKER_LABELS.get(platform.strip().lower())
if label:
return label
if platform == "codex":
mode = "auto"
codex_cfg = config.get("codex") if isinstance(config, dict) else None
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
def filter_platform(content: str, platform: str) -> str:
"""Keep lines outside any `[...]` block + lines inside blocks that include platform.
Marker lines themselves are dropped from the output.
"""
lines = content.splitlines()
out: list[str] = []
in_block = False
keep_block = False
for line in lines:
marker = _parse_marker(line)
if marker is not None:
is_closing, names = marker
if not is_closing:
in_block = True
keep_block = _platform_matches(platform, names)
else:
in_block = False
keep_block = False
continue # drop the marker line itself
if in_block:
if keep_block:
out.append(line)
continue
out.append(line)
# Collapse runs of 3+ blank lines that may arise from dropped markers
collapsed: list[str] = []
blank_run = 0
for line in out:
if line.strip() == "":
blank_run += 1
if blank_run <= 2:
collapsed.append(line)
else:
blank_run = 0
collapsed.append(line)
return "\n".join(collapsed).rstrip() + "\n"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""
Get Session Context for AI Agent.
Usage:
python3 get_context.py Output context in text format
python3 get_context.py --json Output context in JSON format
"""
from __future__ import annotations
from common.git_context import main
if __name__ == "__main__":
main()
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""
Get current developer name.
This is a wrapper that uses common/paths.py
"""
from __future__ import annotations
import sys
from common.paths import get_developer
def main() -> None:
"""CLI entry point."""
developer = get_developer()
if developer:
print(developer)
else:
print("Developer not initialized", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Linear sync hook for Trellis task lifecycle.
Syncs task events to Linear via the `linearis` CLI.
Usage (called automatically by task.py hooks):
python3 .trellis/scripts/hooks/linear_sync.py create
python3 .trellis/scripts/hooks/linear_sync.py start
python3 .trellis/scripts/hooks/linear_sync.py archive
Manual usage:
TASK_JSON_PATH=.trellis/tasks/<name>/task.json python3 .trellis/scripts/hooks/linear_sync.py sync
Environment:
TASK_JSON_PATH - Absolute path to task.json (set by task.py)
Configuration:
.trellis/hooks.local.json - Local config (gitignored), example:
{
"linear": {
"team": "TEAM_KEY",
"project": "Project Name",
"assignees": {
"dev-name": "linear-user-id"
}
}
}
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
# ─── Configuration ────────────────────────────────────────────────────────────
# Trellis priority → Linear priority (1=Urgent, 2=High, 3=Medium, 4=Low)
PRIORITY_MAP = {"P0": 1, "P1": 2, "P2": 3, "P3": 4}
# Linear status names (must match your team's workflow)
STATUS_IN_PROGRESS = "In Progress"
STATUS_DONE = "Done"
def _load_config() -> dict:
"""Load local hook config from .trellis/hooks.local.json."""
task_json_path = os.environ.get("TASK_JSON_PATH", "")
if task_json_path:
# Walk up from task.json to find .trellis/
trellis_dir = Path(task_json_path).parent.parent.parent
else:
trellis_dir = Path(".trellis")
config_path = trellis_dir / "hooks.local.json"
try:
with open(config_path, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
CONFIG = _load_config()
LINEAR_CFG = CONFIG.get("linear", {})
TEAM = LINEAR_CFG.get("team", "")
PROJECT = LINEAR_CFG.get("project", "")
ASSIGNEE_MAP = LINEAR_CFG.get("assignees", {})
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _read_task() -> tuple[dict, str]:
path = os.environ.get("TASK_JSON_PATH", "")
if not path:
print("TASK_JSON_PATH not set", file=sys.stderr)
sys.exit(1)
with open(path, encoding="utf-8") as f:
return json.load(f), path
def _write_task(data: dict, path: str) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def _linearis(*args: str) -> dict | None:
result = subprocess.run(
["linearis", *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
print(f"linearis error: {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
stdout = result.stdout.strip()
if stdout:
return json.loads(stdout)
return None
def _get_linear_issue(task: dict) -> str | None:
meta = task.get("meta")
if isinstance(meta, dict):
return meta.get("linear_issue")
return None
# ─── Actions ──────────────────────────────────────────────────────────────────
def cmd_create() -> None:
if not TEAM:
print("No linear.team configured in hooks.local.json", file=sys.stderr)
sys.exit(1)
task, path = _read_task()
# Skip if already linked
if _get_linear_issue(task):
print(f"Already linked: {_get_linear_issue(task)}")
return
title = task.get("title") or task.get("name") or "Untitled"
args = ["issues", "create", title, "--team", TEAM]
# Map priority
priority = PRIORITY_MAP.get(task.get("priority", ""), 0)
if priority:
args.extend(["-p", str(priority)])
# Set project
if PROJECT:
args.extend(["--project", PROJECT])
# Assign to Linear user
assignee = task.get("assignee", "")
linear_user_id = ASSIGNEE_MAP.get(assignee)
if linear_user_id:
args.extend(["--assignee", linear_user_id])
# Link to parent's Linear issue if available
parent_issue = _resolve_parent_linear_issue(task)
if parent_issue:
args.extend(["--parent-ticket", parent_issue])
result = _linearis(*args)
if result and "identifier" in result:
if not isinstance(task.get("meta"), dict):
task["meta"] = {}
task["meta"]["linear_issue"] = result["identifier"]
_write_task(task, path)
print(f"Created Linear issue: {result['identifier']}")
def cmd_start() -> None:
task, _ = _read_task()
issue = _get_linear_issue(task)
if not issue:
return
_linearis("issues", "update", issue, "-s", STATUS_IN_PROGRESS)
print(f"Updated {issue} -> {STATUS_IN_PROGRESS}")
cmd_sync()
def cmd_archive() -> None:
task, _ = _read_task()
issue = _get_linear_issue(task)
if not issue:
return
_linearis("issues", "update", issue, "-s", STATUS_DONE)
print(f"Updated {issue} -> {STATUS_DONE}")
def cmd_sync() -> None:
"""Sync prd.md content to Linear issue description."""
task, _ = _read_task()
issue = _get_linear_issue(task)
if not issue:
print("No linear_issue in meta, run create first", file=sys.stderr)
sys.exit(1)
# Find prd.md next to task.json
task_json_path = os.environ.get("TASK_JSON_PATH", "")
prd_path = Path(task_json_path).parent / "prd.md"
if not prd_path.is_file():
print(f"No prd.md found at {prd_path}", file=sys.stderr)
sys.exit(1)
description = prd_path.read_text(encoding="utf-8").strip()
_linearis("issues", "update", issue, "-d", description)
print(f"Synced prd.md to {issue} description")
# ─── Parent Issue Resolution ─────────────────────────────────────────────────
def _resolve_parent_linear_issue(task: dict) -> str | None:
"""Find parent task's Linear issue identifier."""
parent_name = task.get("parent")
if not parent_name:
return None
task_json_path = os.environ.get("TASK_JSON_PATH", "")
if not task_json_path:
return None
current_task_dir = Path(task_json_path).parent
tasks_dir = current_task_dir.parent
parent_json = tasks_dir / parent_name / "task.json"
if parent_json.exists():
try:
with open(parent_json, encoding="utf-8") as f:
parent_task = json.load(f)
return _get_linear_issue(parent_task)
except (json.JSONDecodeError, OSError):
pass
return None
# ─── Main ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
action = sys.argv[1] if len(sys.argv) > 1 else ""
actions = {
"create": cmd_create,
"start": cmd_start,
"archive": cmd_archive,
"sync": cmd_sync,
}
fn = actions.get(action)
if fn:
fn()
else:
print(f"Unknown action: {action}", file=sys.stderr)
print(f"Valid actions: {', '.join(actions)}", file=sys.stderr)
sys.exit(1)
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Initialize developer for workflow.
Usage:
python3 init_developer.py <developer-name>
This creates:
- .trellis/.developer file with developer info
- .trellis/workspace/<name>/ directory structure
"""
from __future__ import annotations
import sys
from common.paths import (
DIR_WORKFLOW,
FILE_DEVELOPER,
get_developer,
)
from common.developer import init_developer
def main() -> None:
"""CLI entry point."""
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <developer-name>")
print()
print("Example:")
print(f" {sys.argv[0]} john")
sys.exit(1)
name = sys.argv[1]
# Check if already initialized
existing = get_developer()
if existing:
print(f"Developer already initialized: {existing}")
print()
print(f"To reinitialize, remove {DIR_WORKFLOW}/{FILE_DEVELOPER} first")
sys.exit(0)
if init_developer(name):
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()
+801
View File
@@ -0,0 +1,801 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Task Management Script.
Usage:
python3 task.py create "<title>" --description "<desc>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>] [--package <pkg>] [--no-start] [--force]
python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
python3 task.py validate <dir> # Validate jsonl files
python3 task.py list-context <dir> # List jsonl entries
python3 task.py start <dir> # Set active task, record current branch
python3 task.py current [--source] [--json] # Show active task
python3 task.py finish # Clear active task
python3 task.py set-branch <dir> <branch> # Set git branch
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
python3 task.py set-scope <dir> <scope> # Set scope for PR title
python3 task.py set-meta <dir> <key> <value> # Set a task metadata key
python3 task.py rename <dir> <new-slug> [--dry-run] # Rename task + references
python3 task.py archive <task-dir> [--skip-branch-validation] # Archive completed task
python3 task.py list # List active tasks
python3 task.py list-archive [month] # List archived tasks
python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from common.log import Colors, colored
from common.paths import (
DEVELOPER_HINT,
DIR_WORKFLOW,
DIR_TASKS,
FILE_TASK_JSON,
get_repo_root,
get_developer,
get_tasks_dir,
get_current_task,
)
from common.active_task import (
clear_active_task,
resolve_active_task,
resolve_context_key,
set_active_task,
)
from common.git import current_branch_name
from common.io import (
describe_json_read_failure,
read_json_checked,
write_json,
)
from common.task_utils import resolve_task_dir, run_task_hooks
from common.tasks import iter_active_tasks, children_progress
# Import command handlers from split modules (also re-exports for plan.py compatibility)
from common.task_store import (
cmd_create,
cmd_rename,
cmd_archive,
cmd_set_branch,
cmd_set_base_branch,
cmd_set_scope,
cmd_set_meta,
cmd_add_subtask,
cmd_remove_subtask,
)
from common.task_context import (
cmd_add_context,
cmd_validate,
cmd_list_context,
curated_entry_count,
)
# =============================================================================
# Command: start / finish
# =============================================================================
def _record_start_state(
task_json_path: Path,
repo_root: Path,
label: str = "",
) -> None:
"""Move a freshly started task to in_progress and record its branch.
Both updates share one read/write: the status flip from planning, and the
checked-out branch when `branch` is still empty. Recording at start is what
keeps `branch` trustworthy at archive time — a task whose branch is only
ever set by hand tends to reach archive with `branch: null`.
Tolerant on purpose — a broken task.json does not fail `start`, because the
session pointer is the point of the command. But the read overwrites the
file it just read, so no failure may be silent: without a message the
absent status line looks like the task simply was not in planning.
"""
data, reason = read_json_checked(task_json_path)
if data is None:
problem, hint = describe_json_read_failure(task_json_path, reason)
print(
colored(f"Warning: {problem}; task.json not updated.", Colors.YELLOW),
file=sys.stderr,
)
print(hint, file=sys.stderr)
return
applied: list[str] = []
if data.get("status") == "planning":
data["status"] = "in_progress"
applied.append(f"✓ Status: planning → in_progress{label}")
# Only fill an empty field: an explicit `set-branch` must survive a later
# `start` (re-starting a task after a checkout is a normal thing to do).
base_branch_conflict: str | None = None
if not data.get("branch"):
branch = current_branch_name(repo_root)
if branch:
data["branch"] = branch
applied.append(f"✓ Branch recorded: {branch}{label}")
if branch == data.get("base_branch"):
base_branch_conflict = branch
else:
print(
colored(
"Note: no checked-out branch (detached HEAD, or not a git "
"repository); task branch not recorded.",
Colors.YELLOW,
),
file=sys.stderr,
)
if not applied:
return
if not write_json(task_json_path, data):
print(
colored(
f"Warning: Failed to write {task_json_path}; "
"status and branch are unchanged.",
Colors.YELLOW,
),
file=sys.stderr,
)
return
for line in applied:
print(colored(line, Colors.GREEN))
if base_branch_conflict:
# Recorded anyway — the value is true, it just cannot describe a PR.
# Archive refuses this shape, so say so now rather than at the gate.
print(
colored(
f"Warning: '{base_branch_conflict}' is also this task's base_branch; "
"a PR cannot target its own branch, and archive will refuse it.",
Colors.YELLOW,
),
file=sys.stderr,
)
print(
f"Once you branch off, run: python3 {DIR_WORKFLOW}/scripts/task.py "
"set-branch <task> <feature-branch>",
file=sys.stderr,
)
def cmd_start(args: argparse.Namespace) -> int:
"""Set active task."""
repo_root = get_repo_root()
task_input = args.dir
if not task_input:
print(colored("Error: task directory or name required", Colors.RED))
return 1
# Resolve task directory (supports task name, relative path, or absolute path)
full_path = resolve_task_dir(task_input, repo_root)
if full_path is None:
# resolve_task_dir already named the exact reason on stderr. A second,
# generic line on stdout would split one diagnosis across two streams
# and bury the specific message.
return 1
if not full_path.is_dir():
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
return 1
# Context-manifest gate (#573): a seeded-but-uncurated implement/check
# manifest means every sub-agent dispatched for this task runs with zero
# spec context, and nothing downstream surfaces that to the main session.
# An absent manifest is not gated — create seeds the files only on
# sub-agent-capable platforms, so absence means no sub-agent reads them.
if not getattr(args, "allow_empty_context", False):
empty_manifests = [
name
for name in ("implement.jsonl", "check.jsonl")
if curated_entry_count(full_path / name) == 0
]
if empty_manifests:
print(colored(
f"Error: {' and '.join(empty_manifests)} "
f"{'has' if len(empty_manifests) == 1 else 'have'} no curated entries",
Colors.RED,
))
print("Sub-agents (implement/check) would run with zero spec context.")
print(f" Curate: python3 .trellis/scripts/task.py add-context {task_input} implement <path> \"<why>\"")
print(f" Verify: python3 .trellis/scripts/task.py validate {task_input}")
print(" Intentionally empty? Re-run start with --allow-empty-context")
return 1
# Convert to relative path for storage. repo_root is resolved because
# full_path already is (resolve_task_dir only returns paths inside the
# resolved root), so an unresolved repo_root would mismatch under a
# symlink (e.g. /tmp on macOS) and reject a perfectly normal task.
try:
task_dir = full_path.relative_to(repo_root.resolve()).as_posix()
except ValueError:
# resolve_task_dir already refused everything outside the repo, so
# this is unreachable in practice. Refuse rather than fall back to
# str(full_path) — that fallback (a lexical relative_to() paired with
# an absolute-path fallback) is exactly the pattern that let a `..`
# ref escape into storage before this fix.
print(colored(f"Error: Task not found: {task_input}", Colors.RED))
print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
return 1
task_json_path = full_path / FILE_TASK_JSON
if not resolve_context_key():
# Degraded mode: no session identity available.
# Hook didn't inject TRELLIS_CONTEXT_ID (common on Windows + Claude Code,
# --continue resume path, fork distribution, hooks disabled, etc.). Skip
# per-session pointer write; AI continues based on conversation context.
print(colored(
"ℹ Session identity not available; active-task pointer not persisted "
"this session (degraded mode). AI continues based on conversation context.",
Colors.YELLOW,
))
print(colored(
"Hint: run inside an AI IDE/session that exposes session identity, "
"or set TRELLIS_CONTEXT_ID before running task.py start.",
Colors.YELLOW,
))
# Still flip task.json status: planning → in_progress so downstream phases proceed.
if task_json_path.is_file():
_record_start_state(task_json_path, repo_root, " (degraded)")
run_task_hooks("after_start", task_json_path, repo_root)
return 0
active = set_active_task(task_dir, repo_root)
if active:
print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
print(f"Source: {active.source}")
if task_json_path.is_file():
_record_start_state(task_json_path, repo_root)
print()
print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
run_task_hooks("after_start", task_json_path, repo_root)
return 0
else:
print(colored("Error: Failed to set current task", Colors.RED))
return 1
def cmd_finish(args: argparse.Namespace) -> int:
"""Clear active task."""
repo_root = get_repo_root()
active = clear_active_task(repo_root)
current = active.task_path
if not current:
print(colored("No current task set", Colors.YELLOW))
return 0
# Resolve task.json path before clearing
task_json_path = repo_root / current / FILE_TASK_JSON
print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
print(f"Source: {active.source}")
if task_json_path.is_file():
run_task_hooks("after_finish", task_json_path, repo_root)
return 0
def cmd_current(args: argparse.Namespace) -> int:
"""Show active task."""
repo_root = get_repo_root()
active = resolve_active_task(repo_root)
if getattr(args, "json", False):
task_obj = None
read_error = None
if active.task_path:
task_json_path = repo_root / active.task_path / FILE_TASK_JSON
data, reason = read_json_checked(task_json_path)
if data is None:
# Without this, a corrupt task.json emits null for every field
# — indistinguishable from a task whose fields really are null.
problem, hint = describe_json_read_failure(task_json_path, reason)
read_error = {
"file": str(task_json_path),
"reason": reason,
"message": f"{problem}. {hint}",
}
data = {}
task_obj = {
"dir": active.task_path,
"id": data.get("id") or data.get("name"),
"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"),
}
payload = {
"current_task": task_obj,
"source": active.source,
"stale": active.stale,
}
# Only present when the read failed, so the healthy shape is unchanged.
if read_error:
payload["error"] = read_error
print(json.dumps(payload, ensure_ascii=False))
return 0 if active.task_path else 1
if args.source:
print(f"Current task: {active.task_path or '(none)'}")
print(f"Source: {active.source}")
if active.stale:
print("State: stale")
return 0 if active.task_path else 1
if active.task_path:
print(active.task_path)
return 0
return 1
# =============================================================================
# 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()
tasks_dir = get_tasks_dir(repo_root)
current_task = get_current_task(repo_root)
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", "hint": DEVELOPER_HINT}),
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:
print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
print(DEVELOPER_HINT, file=sys.stderr)
return 1
print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
else:
print(colored("All active tasks:", Colors.BLUE))
print()
# Display tasks hierarchically
count = 0
def _print_task(dir_name: str, indent: int = 0) -> None:
nonlocal count
t = all_tasks[dir_name]
# Apply --mine filter
if filter_mine and (t.assignee or "-") != developer:
return
# Apply --status filter
if filter_status and t.status != filter_status:
return
relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
marker = ""
if relative_path == current_task:
marker = f" {colored('<- current', Colors.GREEN)}"
# 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 ""
prefix = " " * indent + " - "
if filter_mine:
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress}{marker}")
else:
print(f"{prefix}{dir_name}/ ({status_label}){pkg_tag}{progress} [{colored(t.assignee or '-', Colors.CYAN)}]{marker}")
count += 1
# Print children indented
for child_name in t.children:
if child_name in all_tasks:
_print_task(child_name, indent + 1)
# 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()):
parent = all_tasks[dir_name].parent
if not parent or parent not in all_tasks:
_print_task(dir_name)
if count == 0:
if filter_mine:
print(" (no tasks assigned to you)")
else:
print(" (no active tasks)")
print()
print(f"Total: {count} task(s)")
return 0
# =============================================================================
# Command: list-archive
# =============================================================================
def cmd_list_archive(args: argparse.Namespace) -> int:
"""List archived tasks."""
repo_root = get_repo_root()
tasks_dir = get_tasks_dir(repo_root)
archive_dir = tasks_dir / "archive"
month = args.month
print(colored("Archived tasks:", Colors.BLUE))
print()
if month:
month_dir = archive_dir / month
if month_dir.is_dir():
print(f"[{month}]")
for d in sorted(month_dir.iterdir()):
if d.is_dir():
print(f" - {d.name}/")
else:
print(f" No archives for {month}")
else:
if archive_dir.is_dir():
for month_dir in sorted(archive_dir.iterdir()):
if month_dir.is_dir():
month_name = month_dir.name
count = sum(1 for d in month_dir.iterdir() if d.is_dir())
print(f"[{month_name}] - {count} task(s)")
return 0
# =============================================================================
# Help
# =============================================================================
def show_usage() -> None:
"""Show usage help."""
print("""Task Management Script
Usage:
python3 task.py create <title> --description <desc> Create new task directory (both required, non-empty)
python3 task.py create <title> --description <desc> --package <pkg> Create task for a specific package
python3 task.py create <title> --description <desc> --parent <dir> Create task as child of parent
python3 task.py create <title> --description <desc> --no-start Create without making it active in this session
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
python3 task.py validate <dir> Validate jsonl files
python3 task.py list-context <dir> List jsonl entries
python3 task.py start <dir> Set active task; records the checked-out branch when unset
python3 task.py current [--source] Show active task
python3 task.py finish Clear active task
python3 task.py set-branch <dir> <branch> Set git branch
python3 task.py set-base-branch <dir> <branch> Set PR target branch
python3 task.py set-scope <dir> <scope> Set scope for PR title
python3 task.py set-meta <dir> <key> <value> Set/overwrite a task metadata key
python3 task.py rename <dir> <new-slug> Rename task, identity fields and references
python3 task.py archive <task-dir> Archive completed task
python3 task.py add-subtask <parent> <child> Link child task to parent
python3 task.py remove-subtask <parent> <child> Unlink child from parent
python3 task.py list [--mine] [--status <status>] [--json] List tasks
python3 task.py list-archive [YYYY-MM] List archived tasks
Monorepo options:
--package <pkg> Package name (validated against config.yaml packages)
Rename options:
--dry-run Print the change set without writing anything
Archive options:
--no-commit Skip the auto git commit after archiving
--skip-branch-validation Archive despite missing or self-referential branch metadata.
Archive normally refuses a task with no `branch` when it has a
`base_branch` and the repo has a remote, or with
`branch == base_branch`; repair those with `set-branch` /
`set-base-branch` instead. Use this flag only for tasks that
were never PR-backed. A recorded branch that was merged and
deleted is only a warning and needs no flag.
List options:
--mine, -m Show only tasks assigned to current developer
--status, -s <s> Filter by status (planning, in_progress, review, completed)
--json Output machine-readable JSON (also available on `current`)
Examples:
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login
python3 task.py create "Add login feature" --description "Email + password sign-in" --slug add-login --package cli
python3 task.py create "Add login feature" --description "Email + password sign-in" --meta linear=ENG-123 --meta epic=auth
python3 task.py create "Child task" --description "Session cookie handling" --slug child --parent .trellis/tasks/01-21-parent
python3 task.py add-context <dir> implement .trellis/spec/cli/backend/auth.md "Auth guidelines"
python3 task.py set-branch <dir> task/add-login
python3 task.py start .trellis/tasks/01-21-add-login
python3 task.py current --source
python3 task.py finish
python3 task.py rename add-login add-sso --dry-run # Preview the change set
python3 task.py rename add-login add-sso
python3 task.py archive add-login
python3 task.py archive add-login --skip-branch-validation # Task never had a branch of its own
python3 task.py add-subtask parent-task child-task # Link existing tasks
python3 task.py remove-subtask parent-task child-task
python3 task.py list # List all active tasks
python3 task.py list --mine # List my tasks only
python3 task.py list --mine --status in_progress # List my in-progress tasks
""")
# =============================================================================
# Main Entry
# =============================================================================
def main() -> int:
"""CLI entry point."""
# Deprecation guard: `init-context` was removed in v0.5.0-beta.12.
# Detect early so argparse doesn't mask the real reason with a generic
# "invalid choice" error.
if len(sys.argv) >= 2 and sys.argv[1] == "init-context":
print(
colored(
"Error: `task.py init-context` was removed in v0.5.0-beta.12.",
Colors.RED,
),
file=sys.stderr,
)
print(
"implement.jsonl / check.jsonl are now seeded on `task.py create` for",
file=sys.stderr,
)
print(
"sub-agent-capable platforms and curated by the AI during planning when needed.",
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",
file=sys.stderr,
)
print(
"Use `task.py add-context <dir> implement|check <path> <reason>` to append entries.",
file=sys.stderr,
)
return 2
parser = argparse.ArgumentParser(
description="Task Management Script",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", help="Commands")
# create
p_create = subparsers.add_parser("create", help="Create new task")
p_create.add_argument("title", help="Task title (required, non-empty)")
p_create.add_argument("--slug", "-s", help="Task slug without the MM-DD date prefix")
p_create.add_argument("--assignee", "-a", help="Assignee developer")
p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
p_create.add_argument(
"--description",
"-d",
help="Task description (required, non-empty — an empty one is refused at archive)",
)
p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
p_create.add_argument("--package", help="Package name for monorepo projects")
p_create.add_argument(
"--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",
)
p_create.add_argument(
"--force",
action="store_true",
help="Overwrite task.json when the task directory already exists",
)
# add-context
p_add = subparsers.add_parser("add-context", help="Add context entry")
p_add.add_argument("dir", help="Task directory")
p_add.add_argument("file", help="JSONL file (implement|check)")
p_add.add_argument("path", help="File path to add")
p_add.add_argument("reason", nargs="?", help="Reason for adding")
# validate
p_validate = subparsers.add_parser("validate", help="Validate context files")
p_validate.add_argument("dir", help="Task directory")
# list-context
p_listctx = subparsers.add_parser("list-context", help="List context entries")
p_listctx.add_argument("dir", help="Task directory")
# start
p_start = subparsers.add_parser("start", help="Set active task")
p_start.add_argument("dir", help="Task directory")
p_start.add_argument(
"--allow-empty-context",
action="store_true",
help="Start even when implement.jsonl / check.jsonl have no curated entries",
)
# current
p_current = subparsers.add_parser("current", help="Show active task")
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")
# set-branch
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
p_branch.add_argument("dir", help="Task directory")
p_branch.add_argument("branch", help="Branch name")
# set-base-branch
p_base = subparsers.add_parser("set-base-branch", help="Set PR target branch")
p_base.add_argument("dir", help="Task directory")
p_base.add_argument("base_branch", help="Base branch name (PR target)")
# set-scope
p_scope = subparsers.add_parser("set-scope", help="Set scope")
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")
# rename
p_rename = subparsers.add_parser("rename", help="Rename task and its references")
p_rename.add_argument("name", help="Task directory or name")
p_rename.add_argument("new_slug", help="New slug without the MM-DD date prefix")
p_rename.add_argument(
"--dry-run",
action="store_true",
help="Print the change set without writing anything",
)
# archive
p_archive = subparsers.add_parser("archive", help="Archive task")
p_archive.add_argument("name", help="Task directory or name")
p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
p_archive.add_argument(
"--skip-branch-validation",
action="store_true",
help=(
"Archive even when branch metadata is missing or self-referential "
"(for tasks that were never PR-backed)"
),
)
# list
p_list = subparsers.add_parser("list", help="List tasks")
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")
p_addsub.add_argument("parent_dir", help="Parent task directory")
p_addsub.add_argument("child_dir", help="Child task directory")
# remove-subtask
p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
p_rmsub.add_argument("parent_dir", help="Parent task directory")
p_rmsub.add_argument("child_dir", help="Child task directory")
# list-archive
p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
args = parser.parse_args()
if not args.command:
show_usage()
return 1
commands = {
"create": cmd_create,
"add-context": cmd_add_context,
"validate": cmd_validate,
"list-context": cmd_list_context,
"start": cmd_start,
"current": cmd_current,
"finish": cmd_finish,
"set-branch": cmd_set_branch,
"set-base-branch": cmd_set_base_branch,
"set-scope": cmd_set_scope,
"set-meta": cmd_set_meta,
"rename": cmd_rename,
"archive": cmd_archive,
"add-subtask": cmd_add_subtask,
"remove-subtask": cmd_remove_subtask,
"list": cmd_list,
"list-archive": cmd_list_archive,
}
if args.command in commands:
return commands[args.command](args)
else:
show_usage()
return 1
if __name__ == "__main__":
sys.exit(main())