fix(skills): honor skills-only orchestrator decisions, dedupe runtime helpers, warn on dropped picks

Addresses the code-review findings on 7f5bf641:

- Honor skills-only decisions: RouteDecision.route_name is now Option<String> and the orchestrator emits a decision when routes is empty but skills is non-empty. The LLM handler falls back to the originally-requested model and still injects activated skill bodies, matching the contract in docs/source/resources/skills.rst.
- Warn on allow-list misses: resolve_for_route now returns a SkillResolution that splits drops into "not allow-listed for this route" vs "not in catalog (hallucinated)". brightstaff logs each bucket so misconfigured routing_preferences[].skills lists become visible instead of vanishing silently.
- Consolidate runtime: common::skills_runtime is now the single source of truth (referenced_skills_catalog, resolve_for_route, resolve_selected_skills, augment_system_prompt_with_skills). brightstaff drops its local re-implementations and calls into common.
- Tests: 11 new tests in common::skills_runtime (catalog union, allow-list intersection, dedup, hallucination handling, XML escaping, body size cap) and 6 new tests in brightstaff::handlers::llm::model_selection cover inject_activated_skills_into_request, including the first-system-message rule and the Parts->Text flatten — both now documented on the function.
- Cap skill body size at 32 KiB with a UTF-8-safe tail-trim + marker so an oversized SKILL.md cannot blow the downstream context window.
- XML-escape skill name and base_dir in the <skill_content> wrapper as defense-in-depth (names are validated upstream, but the wrapper sits inside the system prompt).
- Bound find_project_root at \$HOME plus a 30-parent depth cap so CLI invocations outside HOME no longer walk to /.
This commit is contained in:
Spherrrical 2026-05-18 12:39:21 -07:00
parent 7f5bf641bb
commit 5e8d27fd3c
4 changed files with 639 additions and 139 deletions

View file

@ -102,24 +102,55 @@ class Skill:
}
def find_project_root(start: Path | None = None) -> Path:
"""Walk up from `start` looking for `.plano/`, then `.git/`.
_MAX_PROJECT_ROOT_WALK_DEPTH = 30
Falls back to `start` (or cwd) if nothing is found. This matches how
`npx skills add` chooses a project root.
def find_project_root(start: Path | None = None) -> Path:
"""Walk up from ``start`` looking for ``.plano/``, then ``.git/``.
The walk is bounded so a CLI invocation in a deeply-nested or
pathological directory does not iterate all the way to ``/`` on every
call. Two bounds apply, whichever fires first:
* **$HOME**: when ``start`` is inside the user's home directory, the
walk stops at ``$HOME`` itself. We never inspect siblings of
``$HOME`` like ``/Users`` picking up a stray ``.git/`` there would
be more surprising than helpful.
* **Hard depth cap** (``_MAX_PROJECT_ROOT_WALK_DEPTH`` parents): a
defensive fallback for paths outside ``$HOME`` (e.g. ``/tmp/...``)
so we still terminate quickly on absurdly deep trees.
Falls back to ``start`` (or cwd) if nothing is found. This matches how
``npx skills add`` chooses a project root.
"""
base = Path(start or Path.cwd()).resolve()
cur = base
while cur != cur.parent:
try:
home = Path(os.path.expanduser("~")).resolve()
except (OSError, RuntimeError):
home = None
def _ancestors(start_dir: Path) -> list[Path]:
out: list[Path] = []
cur = start_dir
for _ in range(_MAX_PROJECT_ROOT_WALK_DEPTH + 1):
out.append(cur)
if home is not None and cur == home:
break
if cur == cur.parent:
break
cur = cur.parent
return out
ancestors = _ancestors(base)
for cur in ancestors:
if (cur / ".plano").exists():
return cur
cur = cur.parent
cur = base
while cur != cur.parent:
for cur in ancestors:
if (cur / ".git").exists():
return cur
cur = cur.parent
return base