feat(trellis): enhance bundled skills and workflow integration

- Updated bundled skills documentation to clarify the structure and usage across all platforms, ensuring consistency in skill root paths.
- Introduced a new `inject-spec-context.py` hook for path-scoped spec context injection, improving the relevance of injected specs during file interactions.
- Enhanced existing hooks to support workflow resolution, allowing for dynamic selection of workflows based on task context.
- Added a command to manage workflow selections for active tasks, enabling better task management and workflow adherence.
- Updated configuration options for spec injection, including character limits and refresh windows, to optimize performance and usability.
This commit is contained in:
yuxuanhui
2026-09-07 11:23:15 +08:00
parent ca3bd7a4b1
commit e567e5f717
23 changed files with 2525 additions and 130 deletions
+52 -16
View File
@@ -10,19 +10,24 @@ The emitted ``hookEventName`` field is platform-aware: most hosts expect
CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed
its per-turn event to ``BeforeAgent`` and its schema validator rejects the
legacy name. ``_detect_platform`` picks the right value at runtime.
Breadcrumb text is pulled exclusively from workflow.md
[workflow-state:STATUS] tag blocks — workflow.md is the single source of
truth. There are no fallback dicts in this script: when workflow.md is
Breadcrumb text is pulled exclusively from the resolved workflow file's
[workflow-state:STATUS] tag blocks — the active task may select a
per-task variant (`.trellis/workflows/<id>.md` via task.json `workflow`),
otherwise personal, team, and global defaults are resolved in order.
There are no fallback dicts in this script: when the resolved workflow is
missing or a tag is absent, the breadcrumb degrades to a generic
"Refer to workflow.md for current step." line so users see (and fix)
the broken state instead of the hook silently masking it.
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder,
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI
Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM
in templates/shared-hooks/index.ts — currently Claude, Codex, Gemini,
Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae and ZCode. That table is the
source of truth; each listed platform's collect<Platform>Templates() pulls
this file into its template map through collectSharedHooks(), and a single
writer puts that map on disk at init time. Kiro wires this via the CLI
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context). Written to
each platform's hooks directory via writeSharedHooks() at init time.
(Kiro adds hook stdout directly to the conversation context).
Silent exit 0 cases (no output):
- No .trellis/ directory found (not a Trellis project)
@@ -95,11 +100,16 @@ def find_trellis_root(start: Path) -> Optional[Path]:
def _detect_platform(input_data: dict) -> str | None:
if isinstance(input_data.get("cursor_version"), str):
return "cursor"
# CLAUDE_PROJECT_DIR is a compatibility alias that several hosts set
# alongside their own variable — CodeBuddy, ZCode and Trae all do. It must
# therefore be checked LAST, or every one of them is detected as claude and
# the context key becomes `claude_<their-session-id>`. That key does not
# match the session file `task.py start` wrote under the host's real name,
# so every turn reports no_task while the pointer exists on disk.
# Observed on CodeBuddy IDE 4.10.4: session file `codebuddy_ae54840e….json`
# alongside marker `update-check-claude_ae54840e….marker`, same id.
env_map = {
# ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check
# ZCODE first so ZCode sessions aren't misdetected as claude.
"ZCODE_PROJECT_DIR": "zcode",
"CLAUDE_PROJECT_DIR": "claude",
"CURSOR_PROJECT_DIR": "cursor",
"CODEBUDDY_PROJECT_DIR": "codebuddy",
"FACTORY_PROJECT_DIR": "droid",
@@ -108,6 +118,8 @@ def _detect_platform(input_data: dict) -> str | None:
"KIRO_PROJECT_DIR": "kiro",
"COPILOT_PROJECT_DIR": "copilot",
"TRAE_PROJECT_DIR": "trae",
# Last: the shared alias, only meaningful once no vendor key matched.
"CLAUDE_PROJECT_DIR": "claude",
}
for env_name, platform in env_map.items():
if os.environ.get(env_name):
@@ -183,16 +195,40 @@ _TAG_RE = re.compile(
re.DOTALL,
)
def load_breadcrumbs(root: Path) -> dict[str, str]:
"""Parse workflow.md for [workflow-state:STATUS] blocks.
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.
Returns {status: body_text}. workflow.md is the single source of
The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]
return resolve_workflow_md(
root, input_data, platform=_detect_platform(input_data)
)
except Exception:
return root / ".trellis" / "workflow.md"
def load_breadcrumbs(root: Path, input_data: dict) -> dict[str, str]:
"""Parse the resolved workflow file for [workflow-state:STATUS] blocks.
Returns {status: body_text}. The workflow file is the single source of
truth — there are no fallback dicts in this script. Missing tags
(or a missing/unreadable workflow.md) fall back to a generic line
(or a missing/unreadable workflow file) fall back to a generic line
in build_breadcrumb so users see the broken state and fix
workflow.md, rather than the hook silently masking the issue.
The active task's per-task workflow selection (task.json `workflow`
field) is honored via _resolve_workflow_md; without a selection this
reads the global .trellis/workflow.md exactly as before.
"""
workflow = root / ".trellis" / "workflow.md"
workflow = _resolve_workflow_md(root, input_data)
if not workflow.is_file():
return {}
try:
@@ -411,7 +447,7 @@ def main() -> int:
if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
return 0 # user opted out of the per-turn breadcrumb for this turn
templates = load_breadcrumbs(root)
templates = load_breadcrumbs(root, data)
platform = _detect_platform(data)
task = get_active_task(root, data)
if task is None: