diff --git a/docs/governance.md b/docs/governance.md index d7b3b077..f8e0e121 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -104,6 +104,20 @@ etc.) since workstream templates were merged into the skills system in v0.8.0. Discovery view has search bar, result cards, and "Import from GitHub" modal. - SDK: `discover_skills(q)` and `install_skill(source, skill_id=..., url=...)` on both Python and TypeScript console clients. +- **Runtime config on installed skills**: Installed (readonly) skills can have + their runtime configuration edited — model, temperature, reasoning effort, + token budget, max tokens, agent max turns, auto-approve, allowed tools, + and enabled flag. The server restricts updates to these fields only via + `_SKILL_RUNTIME_CONFIG_FIELDS` filtering; spec/content fields (name, + description, tags, license, compatibility, content, activation) remain + immutable. The admin UI shows "Save Config" instead of "Save" for these + skills. Audit action: `skill.update.config`. +- **Admin UI**: Create/Edit skill modals use a two-column spec manifest layout + (left: Identity / Manifest / Deployment; right: Skill Content editor with + monospace font). Runtime Config is a collapsible 3-column grid below. + License uses an SPDX identifier dropdown (MIT, Apache-2.0, GPL-3.0, etc.). + Installed skills show a cyan origin badge with source URL, spec fields are + disabled, and all collapsible sections auto-expand in view mode. ### Usage Tracking diff --git a/tests/test_skill_parser.py b/tests/test_skill_parser.py index 77068314..b166d36f 100644 --- a/tests/test_skill_parser.py +++ b/tests/test_skill_parser.py @@ -394,6 +394,36 @@ Content. result = parse_skill_md(raw) assert result.version == "1.0.0" + def test_null_description_falls_back_to_body(self) -> None: + """YAML null description must not produce 'None' string.""" + raw = """\ +--- +name: null-desc +description: +--- + +First paragraph here. +""" + result = parse_skill_md(raw) + assert result.description == "First paragraph here." + assert "None" not in result.description + + def test_null_license_and_compatibility(self) -> None: + """YAML null license/compatibility must not produce 'None' string.""" + raw = """\ +--- +name: null-fields +description: Test +license: +compatibility: +--- + +Content. +""" + result = parse_skill_md(raw) + assert result.license == "" + assert result.compatibility == "" + class TestStandardFieldLengths: """Spec caps: description <= 1024, compatibility <= 500.""" diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 1b891abb..33727f02 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -862,6 +862,7 @@ class ChatSession: try: search_skills = list_skills_by_activation("search", enabled_only=True, limit=30) except Exception: + log.warning("session.skill_catalog_failed", exc_info=True) search_skills = [] if search_skills: catalog_lines = [""] diff --git a/turnstone/core/skill_parser.py b/turnstone/core/skill_parser.py index 17a7bd53..ed19d17d 100644 --- a/turnstone/core/skill_parser.py +++ b/turnstone/core/skill_parser.py @@ -199,7 +199,8 @@ def parse_skill_md(raw: str, *, lenient: bool = False) -> ParsedSkill | None: raise ValueError(name_err) # Description — frontmatter or first paragraph of body - description = str(meta.get("description", "")).strip() + raw_desc = meta.get("description") + description = str(raw_desc).strip() if raw_desc is not None else "" if not description and body: first_line = body.split("\n")[0].strip() # Skip markdown headings @@ -220,7 +221,8 @@ def parse_skill_md(raw: str, *, lenient: bool = False) -> ParsedSkill | None: ) description = description[:_MAX_DESCRIPTION_LEN] - compatibility = str(meta.get("compatibility", "")).strip() + raw_compat = meta.get("compatibility") + compatibility = str(raw_compat).strip() if raw_compat is not None else "" if len(compatibility) > _MAX_COMPATIBILITY_LEN: log.warning( "skill_parser.compatibility_truncated", @@ -238,7 +240,7 @@ def parse_skill_md(raw: str, *, lenient: bool = False) -> ParsedSkill | None: version=_extract_str(meta, "version", default="1.0.0"), # Standard uses "allowed-tools" (hyphenated); stored internally as allowed_tools allowed_tools=_extract_list(meta, "allowed-tools"), - license=str(meta.get("license", "")).strip(), + license=_extract_str(meta, "license"), compatibility=compatibility, raw_frontmatter=meta, ) diff --git a/turnstone/core/storage/_postgresql.py b/turnstone/core/storage/_postgresql.py index 2c729a9d..3b796dc3 100644 --- a/turnstone/core/storage/_postgresql.py +++ b/turnstone/core/storage/_postgresql.py @@ -1697,7 +1697,7 @@ class PostgreSQLBackend: .order_by(prompt_templates.c.name) ) if enabled_only: - q = q.where(prompt_templates.c.enabled == True) # noqa: E712 + q = q.where(prompt_templates.c.enabled == 1) if limit > 0: q = q.limit(limit) rows = conn.execute(q).fetchall() diff --git a/turnstone/core/storage/_sqlite.py b/turnstone/core/storage/_sqlite.py index 90dda897..c20cc72c 100644 --- a/turnstone/core/storage/_sqlite.py +++ b/turnstone/core/storage/_sqlite.py @@ -1721,7 +1721,7 @@ class SQLiteBackend: .order_by(prompt_templates.c.name) ) if enabled_only: - q = q.where(prompt_templates.c.enabled == True) # noqa: E712 + q = q.where(prompt_templates.c.enabled == 1) if limit > 0: q = q.limit(limit) rows = conn.execute(q).fetchall()