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:
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 []
|
||||
Reference in New Issue
Block a user