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:
@@ -23,8 +23,15 @@ DIR_WORKFLOW = ".trellis"
|
||||
DIR_TASKS = "tasks"
|
||||
DIR_RUNTIME = ".runtime"
|
||||
DIR_SESSIONS = "sessions"
|
||||
DIR_CURSOR_SHELL = "cursor-shell"
|
||||
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
|
||||
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")
|
||||
@@ -50,35 +57,75 @@ _KNOWN_PLATFORMS = {
|
||||
"snow",
|
||||
}
|
||||
|
||||
# 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, ...]], ...] = (
|
||||
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
|
||||
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
|
||||
("cursor", ("CURSOR_SESSION_ID",)),
|
||||
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_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",)),
|
||||
("droid", ("FACTORY_SESSION_ID", "DROID_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",)),
|
||||
("codebuddy", ("CODEBUDDY_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")),
|
||||
("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
|
||||
# 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_SESSION_ID",)),
|
||||
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children.
|
||||
# TRELLIS_CONTEXT_ID remains the preferred override when present.
|
||||
("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, ...]], ...] = (
|
||||
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
|
||||
("codex", ("CODEX_TRANSCRIPT_PATH",)),
|
||||
# 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",)),
|
||||
@@ -90,11 +137,15 @@ _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.
|
||||
# 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",
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +273,12 @@ 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)
|
||||
@@ -275,8 +332,12 @@ def _find_repo_root_from_cwd() -> Path | None:
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
|
||||
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:
|
||||
@@ -334,7 +395,7 @@ def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> b
|
||||
|
||||
created_at = ticket.get("created_at_epoch")
|
||||
if isinstance(created_at, (int, float)):
|
||||
if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS:
|
||||
if now - created_at <= SHELL_TICKET_TTL_SECONDS:
|
||||
return True
|
||||
_remove_file(ticket_path)
|
||||
return False
|
||||
@@ -352,13 +413,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _matching_cursor_ticket_context_key(
|
||||
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 or ticket.get("platform") != "cursor":
|
||||
if ticket is None:
|
||||
return None
|
||||
if not _ticket_is_fresh(ticket, ticket_path, now):
|
||||
return None
|
||||
@@ -369,29 +435,30 @@ def _matching_cursor_ticket_context_key(
|
||||
return _string_value(ticket.get("context_key"))
|
||||
|
||||
|
||||
def _lookup_cursor_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve Cursor conversation identity from a short-lived shell ticket.
|
||||
def _lookup_shell_ticket_context_key() -> str | None:
|
||||
"""Resolve session identity from a short-lived shell ticket.
|
||||
|
||||
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
|
||||
export it into the shell command environment. The Cursor hook writes a
|
||||
short-lived ticket just before `task.py` runs. We accept a ticket only when
|
||||
the current `task.py` subcommand matches and exactly one fresh context key
|
||||
matches, which avoids cross-window pointer contamination.
|
||||
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
|
||||
|
||||
ticket_dir = _cursor_shell_ticket_dir(repo_root)
|
||||
if not ticket_dir.is_dir():
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
candidates: set[str] = set()
|
||||
for ticket_path in ticket_dir.glob("*.json"):
|
||||
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
|
||||
if context_key:
|
||||
candidates.add(context_key)
|
||||
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))
|
||||
@@ -435,8 +502,10 @@ def resolve_context_key(
|
||||
if env_context_key:
|
||||
return env_context_key
|
||||
|
||||
if allow_environment_context and platform_name in (None, "session", "cursor"):
|
||||
return _lookup_cursor_shell_ticket_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
|
||||
|
||||
|
||||
|
||||
@@ -281,6 +281,24 @@ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
|
||||
return "inline"
|
||||
|
||||
|
||||
def get_default_workflow(repo_root: Path | None = None) -> str | None:
|
||||
"""Return the team-shared default workflow id from config.yaml.
|
||||
|
||||
Reads the top-level ``default_workflow`` key — a slug naming a variant in
|
||||
``.trellis/workflows/<id>.md``. Returns None when unset or blank. This is
|
||||
the git-tracked, team-shared default; a per-developer override lives in the
|
||||
gitignored ``.developer`` file (``workflow=<id>``, see
|
||||
``paths.get_developer_workflow``) and outranks it. Fail-open: a missing or
|
||||
non-string value simply means "no team default" — no warning (this is read
|
||||
on every turn by hooks and must not add per-turn noise).
|
||||
"""
|
||||
config = _load_config(repo_root)
|
||||
raw = config.get("default_workflow")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
return raw.strip() or None
|
||||
|
||||
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
|
||||
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
|
||||
|
||||
@@ -27,6 +27,8 @@ from .packages_context import (
|
||||
get_context_packages_text,
|
||||
get_context_packages_json,
|
||||
)
|
||||
from .paths import get_repo_root
|
||||
from .spec_match import match_specs_for_file
|
||||
from .trellis_config import read_trellis_config
|
||||
from .workflow_phase import (
|
||||
filter_platform,
|
||||
@@ -57,9 +59,9 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
"-m",
|
||||
choices=["default", "record", "packages", "phase"],
|
||||
choices=["default", "record", "packages", "phase", "spec"],
|
||||
default="default",
|
||||
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
|
||||
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction), spec (specs governing a file)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--step",
|
||||
@@ -69,6 +71,10 @@ def main() -> None:
|
||||
"--platform",
|
||||
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
help="File path (absolute or repo-relative) for --mode spec. Lists spec files whose frontmatter paths match it.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -95,6 +101,32 @@ def main() -> None:
|
||||
)
|
||||
content = filter_platform(content, effective)
|
||||
print(content, end="")
|
||||
elif args.mode == "spec":
|
||||
if not args.file:
|
||||
parser.error("--file is required with --mode spec")
|
||||
matches = match_specs_for_file(get_repo_root(), args.file)
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"file": args.file,
|
||||
"matches": [
|
||||
{
|
||||
"path": match.rel_path,
|
||||
"description": match.description,
|
||||
}
|
||||
for match in matches
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
elif matches:
|
||||
for match in matches:
|
||||
print(f"{match.rel_path} — {match.description or '(no description)'}")
|
||||
else:
|
||||
print(f"No spec files declare paths matching {args.file}.")
|
||||
else:
|
||||
if args.json:
|
||||
output_json()
|
||||
|
||||
@@ -94,6 +94,34 @@ def get_developer(repo_root: Path | None = None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_developer_workflow(repo_root: Path | None = None) -> str | None:
|
||||
"""Get the personal workflow override from the .developer file.
|
||||
|
||||
Reads an optional ``workflow=<id>`` line from the gitignored ``.developer``
|
||||
file — the per-developer, git-excluded override that outranks the
|
||||
team-shared ``default_workflow`` in config.yaml. Returns None when the file
|
||||
or the line is absent/blank. Never raises. Additive to ``get_developer``:
|
||||
the ``name=`` reader ignores this line and vice versa.
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
|
||||
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
||||
|
||||
if not dev_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = dev_file.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
if line.startswith("workflow="):
|
||||
return line.split("=", 1)[1].strip() or None
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_developer(repo_root: Path | None = None) -> bool:
|
||||
"""Check if developer is initialized.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Provides:
|
||||
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
|
||||
@@ -417,8 +418,16 @@ def _compare_versions(left: str, right: str) -> int | None:
|
||||
return _compare_prerelease(left_prerelease, right_prerelease)
|
||||
|
||||
|
||||
def _update_marker_path(repo_root: Path) -> Path:
|
||||
context_key = resolve_context_key()
|
||||
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()}"
|
||||
@@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path:
|
||||
)
|
||||
|
||||
|
||||
def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
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:
|
||||
@@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _get_update_hint(repo_root: Path) -> str | None:
|
||||
marker_path = _update_marker_path(repo_root)
|
||||
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
|
||||
|
||||
@@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
_mark_update_check_attempted(repo_root)
|
||||
_mark_update_check_attempted(repo_root, context_key)
|
||||
comparison = _compare_versions(current_version, latest_version)
|
||||
if comparison is None or comparison >= 0:
|
||||
return None
|
||||
@@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None:
|
||||
"""
|
||||
if repo_root is None:
|
||||
repo_root = get_repo_root()
|
||||
update_hint = _get_update_hint(repo_root)
|
||||
update_hint = get_update_hint(repo_root)
|
||||
if update_hint:
|
||||
print(update_hint)
|
||||
print("")
|
||||
|
||||
Executable
+439
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Decision logic for path-scoped spec injection (ticket-refresh model).
|
||||
|
||||
Pure logic only: the per-spec decision engine, block rendering and budgeted
|
||||
payload assembly. Importing this module has no side effects; every piece of IO
|
||||
orchestration (stdin, config, identity, state files, locking, GC) lives in the
|
||||
platform hook that calls it. Unit tests import this module directly.
|
||||
|
||||
Clock
|
||||
The periodic refresh window uses epoch seconds. Context resets are
|
||||
explicit lifecycle events: the hook records an opaque reset identifier,
|
||||
and a mismatch with the last emission re-teaches the spec in full.
|
||||
|
||||
Budget
|
||||
All caps are in characters, because the platform's ``additionalContext``
|
||||
ceiling is 10,000 *characters* (counting bytes made CJK specs pay 3x).
|
||||
Truncation slices code points, which can never split a multi-byte
|
||||
sequence. The per-event cap is enforced on the assembled payload string —
|
||||
wrappers, ``\\n\\n`` separators, index block and tickets all counted — so
|
||||
nothing is ever appended unchecked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
from typing import Any, Sequence
|
||||
|
||||
from .spec_match import SpecMatch
|
||||
|
||||
# Bound on the size of a spec file we are willing to read and hash. A spec
|
||||
# larger than this degrades to an index line (warned) — an inlined body that
|
||||
# big could never fit the budget anyway, and the read+hash would be unbounded.
|
||||
MAX_SPEC_SOURCE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
# Upper bound on the room reserved for named index lines while FULL blocks are
|
||||
# still being packed. Beyond it the reserve falls back to the summary line
|
||||
# alone: a big fan-out must not starve the specs that can still be taught.
|
||||
INDEX_RESERVE_MAX_CHARS = 900
|
||||
|
||||
# State record schema version. Records with any other version are ignored
|
||||
# (safe direction: an ignored record re-injects rather than stays silent).
|
||||
STATE_VERSION = 2
|
||||
|
||||
def _warn(message: str) -> None:
|
||||
print(f"[WARN] spec_inject: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Clock
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def within_window(
|
||||
clock: dict[str, Any],
|
||||
last: dict[str, Any],
|
||||
win_seconds: int,
|
||||
) -> bool:
|
||||
"""True when the last emission is still inside the refresh window (→ stay
|
||||
silent).
|
||||
|
||||
A window of ``0`` means never refresh (infinite window → always True).
|
||||
Missing timestamps or a negative delta are past-window (False → refresh),
|
||||
the safe side of the misfire asymmetry.
|
||||
"""
|
||||
cur_ts = clock.get("ts")
|
||||
last_ts = last.get("ts")
|
||||
if isinstance(cur_ts, (int, float)) and isinstance(last_ts, (int, float)):
|
||||
if win_seconds == 0:
|
||||
return True
|
||||
delta = cur_ts - last_ts
|
||||
return 0 <= delta < win_seconds
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def decide(
|
||||
stateless: bool,
|
||||
last: dict[str, Any] | None,
|
||||
sha256_hex: str,
|
||||
clock: dict[str, Any],
|
||||
win_seconds: int,
|
||||
) -> str:
|
||||
"""Return one of ``"full"`` | ``"ticket"`` | ``"silent"`` for a spec.
|
||||
|
||||
Order is contractual: statelessness first (bounded cost, no state to
|
||||
consult), then first sight, content change, context reset, the refresh
|
||||
window, and finally completeness. A ticket says "you were shown this spec",
|
||||
which is a lie when the recorded FULL was truncated, so an incomplete
|
||||
record is re-taught in full instead.
|
||||
"""
|
||||
if stateless:
|
||||
return "ticket"
|
||||
if last is None:
|
||||
return "full"
|
||||
if last.get("sha256") != sha256_hex:
|
||||
return "full"
|
||||
if clock.get("reset") != last.get("reset"):
|
||||
return "full"
|
||||
|
||||
if within_window(clock, last, win_seconds):
|
||||
return "silent"
|
||||
|
||||
# "complete" is optional and absent means a whole body was shown.
|
||||
if last.get("complete") is False:
|
||||
return "full"
|
||||
return "ticket"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Rendering
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def truncate_chars(text: str, cap: int) -> str:
|
||||
"""Slice ``text`` to at most ``cap`` code points. ``cap <= 0`` = no limit."""
|
||||
if cap <= 0 or len(text) <= cap:
|
||||
return text
|
||||
return text[:cap]
|
||||
|
||||
|
||||
def truncation_notice(rel_path: str, cap: int) -> str:
|
||||
return (
|
||||
f"\n[Trellis: truncated at {cap} characters — "
|
||||
f"read {rel_path} for the full content]"
|
||||
)
|
||||
|
||||
|
||||
def render_full(edited_rel: str, spec_rel: str, sha12: str, body: str) -> str:
|
||||
return (
|
||||
f'<spec-context file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
|
||||
f"{body}\n"
|
||||
f"</spec-context>"
|
||||
)
|
||||
|
||||
|
||||
def render_ticket(
|
||||
edited_rel: str,
|
||||
spec_rel: str,
|
||||
sha12: str,
|
||||
stateless: bool,
|
||||
) -> str:
|
||||
"""Render a ticket block.
|
||||
|
||||
``stateless=True`` covers both the no-identity and circuit-breaker paths:
|
||||
there is no record of a prior emission, so the wording must not claim one.
|
||||
"""
|
||||
if stateless:
|
||||
body = (
|
||||
"This spec governs the file you just touched. If you have not read it in\n"
|
||||
f"this session, Read {spec_rel} before continuing."
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
"You were shown this spec earlier in this session and its content is unchanged.\n"
|
||||
"It still governs edits to matching files. If you no longer remember it, Read\n"
|
||||
f"{spec_rel} before continuing."
|
||||
)
|
||||
return (
|
||||
f'<spec-ticket file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
|
||||
f"{body}\n"
|
||||
f"</spec-ticket>"
|
||||
)
|
||||
|
||||
|
||||
def _index_block(lines: Sequence[str]) -> str:
|
||||
return "<spec-index>\n" + "\n".join(lines) + "\n</spec-index>"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# State records
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_record(
|
||||
rel_path: str,
|
||||
sha256_hex: str,
|
||||
mode: str,
|
||||
clock: dict[str, Any],
|
||||
complete: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a state record. ``complete=False`` marks a FULL whose body was
|
||||
truncated below the whole spec — an absent flag means whole."""
|
||||
record: dict[str, Any] = {
|
||||
"v": STATE_VERSION,
|
||||
"spec": rel_path,
|
||||
"sha256": sha256_hex,
|
||||
"mode": mode,
|
||||
"ts": clock.get("ts"),
|
||||
}
|
||||
if isinstance(clock.get("reset"), str):
|
||||
record["reset"] = clock["reset"]
|
||||
if not complete:
|
||||
record["complete"] = False
|
||||
return record
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Payload assembly
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _derive_fitting_full(
|
||||
edited_rel: str,
|
||||
spec_rel: str,
|
||||
sha12: str,
|
||||
text: str,
|
||||
max_spec_chars: int,
|
||||
fits,
|
||||
) -> tuple[str, bool] | None:
|
||||
"""Largest truncated FULL block that fits the remaining total budget.
|
||||
|
||||
Binary search over the body cap: the rendered block's length is monotone
|
||||
non-decreasing in the cap, so the largest cap whose block still ``fits``
|
||||
is found in ~log2(len(text)) renders (this also absorbs the digit-length
|
||||
wobble of the notice text, which a closed-form estimate cannot).
|
||||
|
||||
The search ceiling is ``max_spec_chars`` when set and the whole body when
|
||||
it is ``0`` (unlimited) — with a ceiling of 1, as an unguarded
|
||||
``max(1, 0)`` would give, nothing but a one-character spec could ever be
|
||||
derived. Returns ``(block, complete)`` — ``complete`` is True only when
|
||||
the winning cap covered the whole body — or None when no non-empty prefix
|
||||
fits (the caller degrades to an index line).
|
||||
"""
|
||||
def candidate_for(cap: int) -> str:
|
||||
body = truncate_chars(text, cap)
|
||||
if len(body) < len(text):
|
||||
body += truncation_notice(spec_rel, cap)
|
||||
return render_full(edited_rel, spec_rel, sha12, body)
|
||||
|
||||
ceiling = len(text) if max_spec_chars <= 0 else min(max_spec_chars, len(text))
|
||||
lo, hi = 1, max(1, ceiling)
|
||||
best: tuple[str, bool] | None = None
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
candidate = candidate_for(mid)
|
||||
if fits(candidate):
|
||||
best = (candidate, mid >= len(text))
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
return best
|
||||
|
||||
|
||||
def _index_line(match: SpecMatch) -> str:
|
||||
return f"- {match.rel_path} — {match.description or 'no description'}"
|
||||
|
||||
|
||||
def assemble_payload(
|
||||
edited_rel: str,
|
||||
matches: Sequence[SpecMatch],
|
||||
stateless: bool,
|
||||
state_records: dict[str, dict[str, Any]],
|
||||
clock: dict[str, Any],
|
||||
max_spec_chars: int,
|
||||
max_total_chars: int,
|
||||
win_seconds: int,
|
||||
match_files: dict[str, str] | None = None,
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Assemble the additionalContext payload from the matched specs.
|
||||
|
||||
Returns ``(payload, records)`` where ``records`` are the state lines to
|
||||
append for the emissions that actually made it into the payload (silent
|
||||
hits and budget-dropped emissions record nothing — they stay eligible).
|
||||
|
||||
Every candidate block is measured against the *assembled* payload string
|
||||
(``"\\n\\n".join(...)``), so the per-event character ceiling holds for the
|
||||
exact string that is emitted.
|
||||
|
||||
``match_files`` maps a governing spec to the first matching file in a
|
||||
multi-file tool call. Single-file callers omit it and retain the original
|
||||
``edited_rel`` behavior.
|
||||
"""
|
||||
blocks: list[str] = []
|
||||
|
||||
def file_for(match: SpecMatch) -> str:
|
||||
return (match_files or {}).get(match.rel_path, edited_rel)
|
||||
|
||||
def fits(candidate: str, reserve: int = 0) -> bool:
|
||||
"""Does ``candidate`` fit the per-event ceiling, keeping ``reserve``
|
||||
characters free for what still has to be appended after it?"""
|
||||
if max_total_chars <= 0:
|
||||
return True
|
||||
return len("\n\n".join([*blocks, candidate])) + reserve <= max_total_chars
|
||||
|
||||
# Reserve while candidates are still pending: the index lines those
|
||||
# candidates would actually need (true strings, not estimates) plus the
|
||||
# summary line — so a derived-cap FULL cannot eat the budget and starve
|
||||
# the specs behind it (measured: 10-spec fan-out at max_total_chars 3000
|
||||
# emitted one 3000-char FULL and dropped the other nine silently). The
|
||||
# named part is only guaranteed within INDEX_RESERVE_MAX_CHARS; beyond
|
||||
# that the reserve falls back to the summary line alone.
|
||||
_all_index_lines = [_index_line(m) for m in matches]
|
||||
_summary_upper = (
|
||||
f"- (+{len(matches)} more governing specs over budget — run "
|
||||
f"python3 ./.trellis/scripts/get_context.py --mode spec "
|
||||
f"--file {edited_rel} to list them)"
|
||||
)
|
||||
_summary_reserve = len("\n\n" + _index_block([_summary_upper]))
|
||||
|
||||
def reserve_for(pending: Sequence[str]) -> int:
|
||||
if not pending:
|
||||
return 0 # Nothing can follow this block — nothing to reserve.
|
||||
named = len("\n\n" + _index_block([*pending, _summary_upper]))
|
||||
if named > INDEX_RESERVE_MAX_CHARS:
|
||||
return _summary_reserve
|
||||
return named
|
||||
|
||||
index_lines: list[str] = []
|
||||
ticket_pending: list[tuple[str, str, str]] = [] # (file, spec, sha256)
|
||||
records: list[dict[str, Any]] = []
|
||||
|
||||
for match_idx, match in enumerate(matches):
|
||||
try:
|
||||
size = match.spec_path.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
if size > MAX_SPEC_SOURCE_BYTES:
|
||||
# Too big to read+hash, let alone inline: name it and move on.
|
||||
_warn(
|
||||
f"{match.rel_path} is {size} bytes (over "
|
||||
f"{MAX_SPEC_SOURCE_BYTES}) — degraded to an index line"
|
||||
)
|
||||
index_lines.append(_index_line(match))
|
||||
continue
|
||||
|
||||
try:
|
||||
data = match.spec_path.read_bytes()
|
||||
except OSError:
|
||||
_warn(f"cannot read {match.rel_path} — skipped")
|
||||
continue
|
||||
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
sha12 = sha256_hex[:12]
|
||||
last = None if stateless else state_records.get(match.rel_path)
|
||||
decision = decide(stateless, last, sha256_hex, clock, win_seconds)
|
||||
|
||||
if decision == "silent":
|
||||
continue
|
||||
|
||||
if decision == "ticket":
|
||||
# Deferred: tickets are counted against the budget last.
|
||||
ticket_pending.append((file_for(match), match.rel_path, sha256_hex))
|
||||
continue
|
||||
|
||||
pending = [*index_lines, *_all_index_lines[match_idx + 1 :]]
|
||||
reserve = reserve_for(pending)
|
||||
_fits = (lambda c: fits(c, reserve))
|
||||
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
body = truncate_chars(text, max_spec_chars)
|
||||
complete = len(body) >= len(text)
|
||||
if not complete:
|
||||
body += truncation_notice(match.rel_path, max_spec_chars)
|
||||
matching_file = file_for(match)
|
||||
block = render_full(matching_file, match.rel_path, sha12, body)
|
||||
if not _fits(block):
|
||||
# Contract amendment 1: before degrading, truncate FURTHER to the
|
||||
# largest body prefix that fits the remaining total budget
|
||||
# (wrapper + notice counted). Without this, the frozen defaults
|
||||
# made the truncation path unreachable (body cap + notice +
|
||||
# wrapper > total cap) and long specs fell straight to an index
|
||||
# line — the rejected index-only mode by another route.
|
||||
derived = _derive_fitting_full(
|
||||
matching_file,
|
||||
match.rel_path,
|
||||
sha12,
|
||||
text,
|
||||
max_spec_chars,
|
||||
_fits,
|
||||
)
|
||||
if derived is not None:
|
||||
derived_block, derived_complete = derived
|
||||
blocks.append(derived_block)
|
||||
records.append(
|
||||
make_record(
|
||||
match.rel_path, sha256_hex, "full", clock, derived_complete
|
||||
)
|
||||
)
|
||||
continue
|
||||
# No usable prefix fits — degrade to an index line, never drop
|
||||
# silently. Not recorded: stays eligible for a later event.
|
||||
index_lines.append(_index_line(match))
|
||||
continue
|
||||
blocks.append(block)
|
||||
records.append(
|
||||
make_record(match.rel_path, sha256_hex, "full", clock, complete)
|
||||
)
|
||||
|
||||
if index_lines:
|
||||
# The index block is budget-bounded too: lines that do not fit collapse
|
||||
# into one summary line (count + how to list them via pull mode) so the
|
||||
# ceiling is honored without silently dropping a governing spec.
|
||||
chosen: list[str] = []
|
||||
dropped = 0
|
||||
for line in index_lines:
|
||||
if fits(_index_block([*chosen, line])):
|
||||
chosen.append(line)
|
||||
else:
|
||||
dropped += 1
|
||||
if dropped:
|
||||
# Contract amendment 3: the summary must actually be reachable.
|
||||
# Greedy packing rarely leaves a summary-sized gap, so pop chosen
|
||||
# lines (re-counting them as dropped) until the summary fits —
|
||||
# only an absurdly small total budget can drop it entirely.
|
||||
while True:
|
||||
noun = "spec" if dropped == 1 else "specs"
|
||||
summary = (
|
||||
f"- (+{dropped} more governing {noun} over budget — run "
|
||||
f"python3 ./.trellis/scripts/get_context.py --mode spec "
|
||||
f"--file {edited_rel} to list them)"
|
||||
)
|
||||
if fits(_index_block([*chosen, summary])):
|
||||
chosen.append(summary)
|
||||
break
|
||||
if not chosen:
|
||||
_warn(
|
||||
f"spec index summary for {edited_rel} dropped — "
|
||||
f"per-event budget exhausted"
|
||||
)
|
||||
break
|
||||
chosen.pop()
|
||||
dropped += 1
|
||||
if chosen:
|
||||
blocks.append(_index_block(chosen))
|
||||
|
||||
for matching_file, spec_rel, sha256_hex in ticket_pending:
|
||||
ticket = render_ticket(
|
||||
matching_file, spec_rel, sha256_hex[:12], stateless
|
||||
)
|
||||
if not fits(ticket):
|
||||
_warn(f"ticket for {spec_rel} dropped — per-event budget exhausted")
|
||||
continue
|
||||
blocks.append(ticket)
|
||||
records.append(make_record(spec_rel, sha256_hex, "ticket", clock))
|
||||
|
||||
return "\n\n".join(blocks), records
|
||||
Executable
+395
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Path-scoped spec matching for on-demand spec injection.
|
||||
|
||||
Spec files under `.trellis/spec/**/*.md` MAY start with a YAML-like
|
||||
frontmatter block declaring which repo paths they govern:
|
||||
|
||||
---
|
||||
name: commands-workflow
|
||||
description: workflow command conventions
|
||||
paths:
|
||||
- packages/cli/src/commands/workflow.ts
|
||||
- packages/cli/src/utils/workflow-resolver.ts
|
||||
---
|
||||
|
||||
The parser is hand-rolled (house pattern, modeled on
|
||||
``trellis_config.parse_simple_yaml`` — no YAML dependency) and reads only a
|
||||
bounded head of each file (16 KiB / 200 lines, whichever ends first). Only
|
||||
files whose first line is exactly ``---`` are considered. ``name:`` /
|
||||
``description:`` single-line strings are recognized (description is reused in
|
||||
index lines). ``paths:`` accepts both a block list (``- <glob>`` items) and a
|
||||
flow sequence (``paths: [a, b]``).
|
||||
|
||||
The parser is deliberately tolerant — a spec is prose that happens to carry a
|
||||
routing hint, not a config file. Unknown keys, unrecognized line shapes and
|
||||
stray ``- item`` lines are ignored; block scalars (``key: >`` / ``key: |``)
|
||||
consume their more-indented continuation lines, so a SKILL.md-style
|
||||
``description: >`` paragraph does not disqualify the file. An opening ``---``
|
||||
with no recognized key before the closing marker is not frontmatter at all
|
||||
(a Markdown horizontal rule opening the prose) and is ignored silently. Two
|
||||
things are errors, and both warn + skip the whole file rather than route on a
|
||||
half-read block: a malformed ``paths:`` (a scalar where a list belongs — that
|
||||
key is the one thing the rest of the pipeline depends on), and a frontmatter
|
||||
block that is still open when the head bound is reached.
|
||||
|
||||
Glob grammar (repo-relative, POSIX separators):
|
||||
|
||||
- ``*`` matches within a single path segment (never crosses ``/``)
|
||||
- ``?`` matches exactly one character within a segment
|
||||
- ``**`` as a whole segment matches zero or more segments
|
||||
- a trailing ``/`` is sugar for ``/**``
|
||||
- ``**`` embedded in a segment with other characters degrades to ``*``
|
||||
|
||||
Validation rejects only what is unsafe or meaningless: empty globs, a leading
|
||||
``/`` (globs are repo-relative), ``..`` segments, backslashes (POSIX
|
||||
separators only) and control characters. Everything else is legal — real
|
||||
repositories carry ``@scope`` packages, ``[slug]`` routes, ``(marketing)``
|
||||
groups and non-ASCII directories, and the translation escapes literals
|
||||
character by character. An invalid glob is skipped with a stderr warning; the
|
||||
rest of the file's globs still apply.
|
||||
|
||||
Translation examples (glob → matches / non-matches):
|
||||
|
||||
packages/cli/src/commands/update.ts
|
||||
matches only that exact file
|
||||
packages/cli/src/commands/*.ts
|
||||
matches packages/cli/src/commands/update.ts
|
||||
not packages/cli/src/commands/channel/spawn.ts
|
||||
packages/cli/src/templates/**
|
||||
matches packages/cli/src/templates/trellis/index.ts (any depth)
|
||||
not packages/cli/src/templates (the directory itself)
|
||||
packages/**/index.ts
|
||||
matches packages/index.ts and packages/cli/src/index.ts
|
||||
src/util?.py
|
||||
matches src/utils.py, not src/util.py or src/utilXY.py
|
||||
packages/cli/
|
||||
same as packages/cli/**
|
||||
|
||||
Provides:
|
||||
SpecMatch - frozen match record (spec_path, rel_path, description)
|
||||
match_specs_for_file - map an edited file to the specs that govern it
|
||||
normalize_repo_relative - the canonical repo-relative path normalization
|
||||
parse_spec_frontmatter - parse the optional frontmatter head block
|
||||
glob_to_regex - deterministic glob → compiled regex translation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import DIR_SPEC, DIR_WORKFLOW
|
||||
from .trellis_config import _strip_inline_comment, _unquote
|
||||
|
||||
# Bounded head-read limits for frontmatter scanning (design contract).
|
||||
HEAD_MAX_BYTES = 16384
|
||||
HEAD_MAX_LINES = 200
|
||||
|
||||
# Recognized frontmatter keys. An opening `---` block that declares none of
|
||||
# them is prose under a horizontal rule, not frontmatter.
|
||||
_KNOWN_KEYS = ("paths", "name", "description")
|
||||
|
||||
_GLOB_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(.*)$")
|
||||
# YAML block-scalar introducers; the value lives in the indented lines below.
|
||||
_BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+")
|
||||
|
||||
# macOS and Windows filesystems are case-insensitive: the very same file can
|
||||
# be handed to us in a case the glob author never wrote. Match case-insensitively
|
||||
# there — over-injecting a spec is the safe side of the asymmetry.
|
||||
_CASE_INSENSITIVE_FS = sys.platform == "darwin" or sys.platform.startswith("win")
|
||||
_GLOB_FLAGS = re.IGNORECASE if _CASE_INSENSITIVE_FS else 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecFrontmatter:
|
||||
"""Parsed frontmatter head. ``paths`` is None when the key is absent."""
|
||||
|
||||
paths: tuple[str, ...] | None
|
||||
name: str | None
|
||||
description: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpecMatch:
|
||||
spec_path: Path
|
||||
"""Absolute path to the spec file."""
|
||||
rel_path: str
|
||||
"""Repo-relative POSIX path, for display."""
|
||||
description: str | None
|
||||
"""Frontmatter ``description:`` value, if declared."""
|
||||
|
||||
|
||||
def _warn(message: str) -> None:
|
||||
print(f"[WARN] spec_match: {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def _parse_flow_sequence(value: str) -> list[str]:
|
||||
"""Split a YAML flow sequence body (``[a, b]``) into unquoted items.
|
||||
|
||||
Commas separate; each item is trimmed and unquoted. Empty items (a
|
||||
trailing comma, ``[]``) collapse away.
|
||||
"""
|
||||
inner = value[1:-1]
|
||||
items = (_unquote(part.strip()).strip() for part in inner.split(","))
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
def _read_head(path: Path) -> str:
|
||||
"""Read at most HEAD_MAX_BYTES from the file, decoded as UTF-8."""
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(HEAD_MAX_BYTES)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def parse_spec_frontmatter(head_text: str) -> SpecFrontmatter | None:
|
||||
"""Parse the optional frontmatter block from a spec file's head.
|
||||
|
||||
Returns None when the file has no frontmatter: either the first line is not
|
||||
``---``, or the block declares no recognized key before its closing marker
|
||||
(a horizontal rule opening a prose file — silent, not an error).
|
||||
|
||||
Raises ValueError on a malformed ``paths:`` key (a scalar where a list
|
||||
belongs) and on a block that is still open when the head bound
|
||||
(HEAD_MAX_LINES / HEAD_MAX_BYTES) is reached — routing on a half-read
|
||||
frontmatter would be worse than skipping the file loudly. Every other line
|
||||
shape is tolerated and ignored.
|
||||
"""
|
||||
lines = head_text.splitlines()[:HEAD_MAX_LINES]
|
||||
if not lines:
|
||||
return None
|
||||
first = lines[0].lstrip("\ufeff") # tolerate a UTF-8 BOM
|
||||
if first != "---":
|
||||
return None
|
||||
|
||||
paths: list[str] | None = None
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
pending_key: str | None = None
|
||||
block_indent: int | None = None
|
||||
saw_known_key = False
|
||||
closed = False
|
||||
|
||||
for line in lines[1:]:
|
||||
stripped = line.strip()
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
if block_indent is not None:
|
||||
# Inside a block scalar: everything more indented (and blank lines)
|
||||
# is its value. A dedent ends the block; that line still counts.
|
||||
if not stripped or indent > block_indent:
|
||||
continue
|
||||
block_indent = None
|
||||
|
||||
if stripped == "---":
|
||||
closed = True
|
||||
break
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
if stripped == "-" or stripped.startswith("- "):
|
||||
if pending_key == "paths" and paths is not None:
|
||||
item = _unquote(_strip_inline_comment(stripped[1:].strip()).strip())
|
||||
paths.append(item)
|
||||
# List items outside `paths:` are tolerated and ignored.
|
||||
continue
|
||||
|
||||
key_match = _KEY_RE.match(stripped)
|
||||
if key_match is None:
|
||||
continue # Unrecognized line shape — tolerated and ignored.
|
||||
|
||||
key = key_match.group(1)
|
||||
saw_known_key = saw_known_key or key in _KNOWN_KEYS
|
||||
raw_value = key_match.group(2).strip()
|
||||
if raw_value in _BLOCK_SCALARS:
|
||||
if key == "paths":
|
||||
raise ValueError("'paths' must be a list of globs")
|
||||
pending_key = None
|
||||
block_indent = indent
|
||||
continue
|
||||
|
||||
value = _unquote(_strip_inline_comment(raw_value).strip())
|
||||
if value:
|
||||
pending_key = None
|
||||
if key == "paths":
|
||||
if not (value.startswith("[") and value.endswith("]")):
|
||||
raise ValueError("'paths' must be a list of globs")
|
||||
paths = _parse_flow_sequence(value)
|
||||
elif key == "name":
|
||||
name = value
|
||||
elif key == "description":
|
||||
description = value
|
||||
# Unknown scalar keys are tolerated and ignored.
|
||||
else:
|
||||
pending_key = key
|
||||
if key == "paths":
|
||||
paths = []
|
||||
|
||||
if not saw_known_key:
|
||||
# An opening `---` with no recognized key is a horizontal rule, not a
|
||||
# frontmatter block. Silent by design: prose files are not malformed.
|
||||
return None
|
||||
if not closed:
|
||||
raise ValueError(
|
||||
f"frontmatter block never closed within the head bound "
|
||||
f"({HEAD_MAX_BYTES} bytes / {HEAD_MAX_LINES} lines)"
|
||||
)
|
||||
|
||||
return SpecFrontmatter(
|
||||
paths=tuple(paths) if paths is not None else None,
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def validate_glob(glob: str) -> str | None:
|
||||
"""Return an error message for an invalid glob, or None when valid.
|
||||
|
||||
Deny-list, not allow-list: only what is unsafe or meaningless is rejected
|
||||
(see module docstring). Everything else — ``@scope``, ``[slug]``,
|
||||
``(marketing)``, non-ASCII directory names — is a legal path in a real
|
||||
repository and translates fine.
|
||||
"""
|
||||
if not glob:
|
||||
return "empty glob"
|
||||
if glob.startswith("/"):
|
||||
return "absolute paths are not allowed (globs are repo-relative)"
|
||||
if ".." in glob.split("/"):
|
||||
return "'..' segments are not allowed"
|
||||
if "\\" in glob:
|
||||
return "backslashes are not allowed (globs use POSIX '/' separators)"
|
||||
if _GLOB_CONTROL_RE.search(glob):
|
||||
return "contains control characters"
|
||||
return None
|
||||
|
||||
|
||||
def glob_to_regex(glob: str) -> re.Pattern[str]:
|
||||
"""Translate a validated glob to a compiled full-match regex.
|
||||
|
||||
Deterministic, segment-based translation (see module docstring for the
|
||||
grammar and examples): ``**`` as a whole segment spans zero or more
|
||||
segments; ``*`` becomes ``[^/]*``; ``?`` becomes ``[^/]``; everything
|
||||
else is escaped literally. A trailing ``/`` is expanded to ``/**`` first.
|
||||
On case-insensitive filesystems (macOS, Windows) the pattern compiles with
|
||||
``re.IGNORECASE`` — see ``_CASE_INSENSITIVE_FS``.
|
||||
"""
|
||||
if glob.endswith("/"):
|
||||
glob += "**"
|
||||
segments = glob.split("/")
|
||||
parts: list[str] = []
|
||||
for i, seg in enumerate(segments):
|
||||
is_last = i == len(segments) - 1
|
||||
if seg == "**":
|
||||
# Last: consume the rest of the path (at least the separator
|
||||
# boundary is already emitted by the previous segment). Not last:
|
||||
# zero or more whole segments including their separators.
|
||||
parts.append(".*" if is_last else r"(?:[^/]+/)*")
|
||||
continue
|
||||
piece = "".join(
|
||||
"[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch)
|
||||
for ch in seg
|
||||
)
|
||||
parts.append(piece if is_last else piece + "/")
|
||||
return re.compile("^" + "".join(parts) + "$", _GLOB_FLAGS)
|
||||
|
||||
|
||||
def normalize_repo_relative(repo_root: Path, file_path: str | Path) -> str | None:
|
||||
"""Canonical repo-relative POSIX path — the one normalization in the
|
||||
pipeline, used both for matching and for display.
|
||||
|
||||
Root and file are fully resolved (``strict=False``, so a file that no
|
||||
longer exists still normalizes): symlinked repo roots, macOS's
|
||||
``/tmp`` → ``/private/tmp`` and ``..`` segments cannot make one file look
|
||||
like two different paths. The result is NFC-normalized (macOS hands out
|
||||
NFD filenames). Relative inputs are taken as repo-relative. Returns None
|
||||
when the file resolves outside the repo.
|
||||
"""
|
||||
try:
|
||||
root = Path(repo_root).resolve(strict=False)
|
||||
candidate = Path(file_path)
|
||||
if not candidate.is_absolute():
|
||||
text = str(file_path).replace("\\", "/")
|
||||
while text.startswith("./"):
|
||||
text = text[2:]
|
||||
if not text:
|
||||
return None
|
||||
candidate = root / text
|
||||
rel = candidate.resolve(strict=False).relative_to(root).as_posix()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return unicodedata.normalize("NFC", rel)
|
||||
|
||||
|
||||
def match_specs_for_file(repo_root: Path, file_path: str | Path) -> list[SpecMatch]:
|
||||
"""Return specs whose frontmatter ``paths:`` globs match file_path.
|
||||
|
||||
``file_path`` may be absolute or repo-relative. More specific matching
|
||||
globs are returned first; ``rel_path`` is the deterministic tie-break.
|
||||
Scans ``.trellis/spec/**/*.md`` with bounded head-reads only. Never raises;
|
||||
unreadable or malformed spec files are skipped with a stderr warning.
|
||||
"""
|
||||
try:
|
||||
repo_root = Path(repo_root).resolve()
|
||||
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
|
||||
if not spec_dir.is_dir():
|
||||
return []
|
||||
rel = normalize_repo_relative(repo_root, file_path)
|
||||
if rel is None:
|
||||
return []
|
||||
|
||||
matches: list[SpecMatch] = []
|
||||
specificity: dict[str, tuple[int, int, int, int]] = {}
|
||||
for spec_file in spec_dir.rglob("*.md"):
|
||||
spec_rel = spec_file.relative_to(repo_root).as_posix()
|
||||
try:
|
||||
head = _read_head(spec_file)
|
||||
except OSError as exc:
|
||||
_warn(f"cannot read {spec_rel}: {exc}")
|
||||
continue
|
||||
try:
|
||||
frontmatter = parse_spec_frontmatter(head)
|
||||
except ValueError as exc:
|
||||
_warn(f"malformed frontmatter in {spec_rel}: {exc}")
|
||||
continue
|
||||
if frontmatter is None or not frontmatter.paths:
|
||||
continue
|
||||
for glob in frontmatter.paths:
|
||||
error = validate_glob(glob)
|
||||
if error is not None:
|
||||
_warn(f"invalid glob {glob!r} in {spec_rel}: {error}")
|
||||
continue
|
||||
if glob_to_regex(glob).match(rel):
|
||||
scored_glob = glob + "**" if glob.endswith("/") else glob
|
||||
wildcard_count = scored_glob.count("*") + scored_glob.count("?")
|
||||
segments = scored_glob.split("/")
|
||||
literal_segments = sum(
|
||||
"*" not in segment and "?" not in segment
|
||||
for segment in segments
|
||||
)
|
||||
specificity[spec_rel] = (
|
||||
0 if wildcard_count == 0 else 1,
|
||||
-literal_segments,
|
||||
wildcard_count,
|
||||
-(len(scored_glob) - wildcard_count),
|
||||
)
|
||||
matches.append(
|
||||
SpecMatch(
|
||||
spec_path=spec_file,
|
||||
rel_path=spec_rel,
|
||||
description=frontmatter.description,
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
# Payload assembly spends its budget in this order. Exact and narrowly
|
||||
# scoped matches must therefore outrank broad tree globs; alphabetic
|
||||
# order is only a deterministic tie-break.
|
||||
matches.sort(key=lambda m: (*specificity[m.rel_path], m.rel_path))
|
||||
return matches
|
||||
except Exception as exc: # Never raise — callers are hooks/context tools.
|
||||
_warn(f"spec scan failed: {exc}")
|
||||
return []
|
||||
@@ -25,7 +25,7 @@ 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 FILE_TASK_JSON, get_repo_root
|
||||
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
|
||||
@@ -170,6 +170,59 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
|
||||
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.
|
||||
|
||||
@@ -220,14 +273,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
||||
continue
|
||||
|
||||
real_entries += 1
|
||||
full_path = repo_root / file_path
|
||||
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
|
||||
if entry_type == "directory":
|
||||
if not full_path.is_dir():
|
||||
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 not full_path.is_file():
|
||||
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
|
||||
|
||||
@@ -56,6 +56,7 @@ from .task_utils import (
|
||||
resolve_task_dir,
|
||||
run_task_hooks,
|
||||
)
|
||||
from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -254,6 +255,31 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
# Inferred: default_package → None (no task.json yet for create)
|
||||
package = resolve_package(repo_root=repo_root)
|
||||
|
||||
# Validate --workflow (CLI source: fail-fast on invalid id; a missing
|
||||
# library file only warns — it may be saved later via `trellis workflow --save`)
|
||||
workflow_id: str | None = getattr(args, "workflow", None)
|
||||
if workflow_id:
|
||||
if not WORKFLOW_ID_RE.match(workflow_id):
|
||||
print(
|
||||
colored(
|
||||
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
|
||||
Colors.RED,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
workflow_md = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
|
||||
if not workflow_md.is_file():
|
||||
print(
|
||||
colored(
|
||||
f"Warning: {DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md does not exist yet; "
|
||||
"default workflow resolution is used until it is saved "
|
||||
"(trellis workflow --save).",
|
||||
Colors.YELLOW,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Default assignee to current developer
|
||||
assignee = args.assignee
|
||||
if not assignee:
|
||||
@@ -385,6 +411,10 @@ def cmd_create(args: argparse.Namespace) -> int:
|
||||
"notes": "",
|
||||
"meta": meta,
|
||||
}
|
||||
# Optional per-task workflow selection: key present only when opted in,
|
||||
# so tasks without a selection keep today's task.json shape byte-for-byte.
|
||||
if workflow_id:
|
||||
task_data["workflow"] = workflow_id
|
||||
|
||||
write_json(task_json_path, task_data)
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .paths import DIR_WORKFLOW, get_repo_root
|
||||
from . import workflow_selection
|
||||
from .paths import get_repo_root
|
||||
|
||||
|
||||
def _workflow_md_path():
|
||||
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
|
||||
return workflow_selection.resolve_workflow_md(get_repo_root())
|
||||
|
||||
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
|
||||
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Per-task workflow selection.
|
||||
|
||||
Resolves which workflow markdown file consumers should read. A task may pin
|
||||
a workflow variant by storing `"workflow": "<id>"` in its task.json; the
|
||||
variant body lives at `.trellis/workflows/<id>.md` (user-managed library).
|
||||
|
||||
Resolution precedence (single source of truth for all consumers), highest
|
||||
to lowest — each layer resolves an id to `.trellis/workflows/<id>.md` and
|
||||
falls through when unset, invalid, or pointing at a missing file:
|
||||
1. Per-task pin - active task's task.json `workflow` (session-bound,
|
||||
explicit; a bad id or missing file warns once on stderr, then falls
|
||||
through rather than aborting).
|
||||
2. Personal - `.developer` `workflow=<id>` (gitignored, per-developer;
|
||||
outranks the team default). Silent on miss.
|
||||
3. Team default - config.yaml `default_workflow` (git-tracked, shared).
|
||||
Silent on miss.
|
||||
4. Global - `.trellis/workflow.md`.
|
||||
With neither a per-task pin nor the personal/team keys set, this is identical
|
||||
to reading the global `.trellis/workflow.md`. Never raises.
|
||||
|
||||
Provides:
|
||||
workflow_md_for_task - Full precedence for an already-resolved task dir
|
||||
resolve_workflow_md - Session-aware wrapper via the active task resolver
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import DIR_WORKFLOW, FILE_TASK_JSON
|
||||
|
||||
# Workflow variant library directory under .trellis/ (plural on purpose:
|
||||
# `.trellis/workflow/` is reserved by the YAML-manifest migration).
|
||||
DIR_WORKFLOWS = "workflows"
|
||||
|
||||
# Workflow ids must be plain slugs; anything else (path separators, dots)
|
||||
# is rejected so a task.json value can never escape .trellis/workflows/.
|
||||
WORKFLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
def _global_workflow_md(repo_root: Path) -> Path:
|
||||
return repo_root / DIR_WORKFLOW / "workflow.md"
|
||||
|
||||
|
||||
def _library_variant(repo_root: Path, workflow_id: str | None) -> Path | None:
|
||||
"""Map a workflow id to its library file if valid and present, else None.
|
||||
|
||||
Shared by every layer (per-task pin, personal, team). An id with path
|
||||
separators/dots/blanks, or one whose `.trellis/workflows/<id>.md` file does
|
||||
not exist, returns None so the caller falls through. Never raises.
|
||||
"""
|
||||
if not isinstance(workflow_id, str) or not workflow_id:
|
||||
return None
|
||||
if not WORKFLOW_ID_RE.match(workflow_id):
|
||||
return None
|
||||
variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
|
||||
return variant if variant.is_file() else None
|
||||
|
||||
|
||||
def _developer_workflow_id(repo_root: Path) -> str | None:
|
||||
"""Personal override id from the gitignored .developer file (fail-open)."""
|
||||
try:
|
||||
from .paths import get_developer_workflow
|
||||
|
||||
return get_developer_workflow(repo_root)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _config_default_id(repo_root: Path) -> str | None:
|
||||
"""Team-shared default id from config.yaml `default_workflow` (fail-open)."""
|
||||
try:
|
||||
from .config import get_default_workflow
|
||||
|
||||
return get_default_workflow(repo_root)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _default_workflow_md(repo_root: Path) -> Path:
|
||||
"""Resolve the non-per-task default: personal -> team -> global.
|
||||
|
||||
Personal (`.developer` `workflow=`) outranks the team-shared
|
||||
(config.yaml `default_workflow`) layer; both fall through to the global
|
||||
`.trellis/workflow.md` when unset, invalid, or naming a missing file. These
|
||||
layers are silent on miss (they are defaults, not an explicit per-task
|
||||
choice — a per-turn warning would be noise).
|
||||
"""
|
||||
for get_id in (_developer_workflow_id, _config_default_id):
|
||||
variant = _library_variant(repo_root, get_id(repo_root))
|
||||
if variant is not None:
|
||||
return variant
|
||||
return _global_workflow_md(repo_root)
|
||||
|
||||
|
||||
def _task_pin_variant(repo_root: Path, task_dir: Path | None) -> Path | None:
|
||||
"""Return the per-task pinned variant path, or None to fall through.
|
||||
|
||||
Emits a stderr warning on an invalid id or a missing variant file (an
|
||||
explicit per-task choice that cannot be honored), then returns None so
|
||||
resolution continues with the personal/team defaults. Never raises.
|
||||
"""
|
||||
if task_dir is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
workflow_id = raw.get("workflow")
|
||||
if not isinstance(workflow_id, str) or not workflow_id:
|
||||
return None
|
||||
|
||||
if not WORKFLOW_ID_RE.match(workflow_id):
|
||||
print(
|
||||
f"Warning: task '{task_dir.name}' has invalid workflow id "
|
||||
f"{workflow_id!r}; using default workflow resolution",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
variant = _library_variant(repo_root, workflow_id)
|
||||
if variant is not None:
|
||||
return variant
|
||||
|
||||
print(
|
||||
f"Warning: task '{task_dir.name}' selects workflow '{workflow_id}' but "
|
||||
f"{DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md is missing; "
|
||||
f"using default workflow resolution",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def workflow_md_for_task(repo_root: Path, task_dir: Path | None) -> Path:
|
||||
"""Return the workflow.md path for an already-resolved task dir (or None).
|
||||
|
||||
Applies the full precedence documented in the module docstring:
|
||||
per-task pin -> personal (.developer) -> team (config.yaml) -> global.
|
||||
Never raises; any failure falls through toward the global workflow path.
|
||||
"""
|
||||
pin = _task_pin_variant(repo_root, task_dir)
|
||||
if pin is not None:
|
||||
return pin
|
||||
return _default_workflow_md(repo_root)
|
||||
|
||||
|
||||
def resolve_workflow_md(
|
||||
repo_root: Path,
|
||||
input_data: dict | None = None,
|
||||
platform: str | None = None,
|
||||
) -> Path:
|
||||
"""Resolve the session-aware active task, then apply the resolution rule.
|
||||
|
||||
``input_data`` is the raw hook payload (session/conversation identity);
|
||||
CLI callers may omit it — the active-task resolver then falls back to
|
||||
environment context. Never raises; any failure resolves to the global
|
||||
`.trellis/workflow.md`.
|
||||
"""
|
||||
try:
|
||||
from .active_task import resolve_active_task, resolve_task_ref
|
||||
|
||||
active = resolve_active_task(repo_root, input_data, platform)
|
||||
task_dir: Path | None = None
|
||||
if active.task_path:
|
||||
task_dir = resolve_task_ref(active.task_path, repo_root)
|
||||
return workflow_md_for_task(repo_root, task_dir)
|
||||
except Exception:
|
||||
return _global_workflow_md(repo_root)
|
||||
Reference in New Issue
Block a user