升级 Trellis 并完成项目审查(07-26-trellis-upgrade-project-audit)

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