Skip to content

Commit 63b62c2

Browse files
committed
fix(deck): close 6 high-confidence findings from skill-refactor review
Architectural review (4 parallel Opus auditors) found that the skill_runner core was already generic, but the deck SURFACE was still fused to Editorial Monocle. Fixed: * validator: now takes optional `grammar` param (DeckGrammar TypedDict); skill-agnostic by default (only checks file present, parses, ≥5 slides, self-contained). Third-party deck skills (guizang, swiss) now pass validation cleanly. Editorial-specific rules opt-in via `EDITORIAL_MONOCLE_GRAMMAR`. (finding #2) * skills/openkb-deck-editorial/SKILL.md: declares its grammar + output_path_template under `od:` frontmatter — `run_skill` reads these and applies them post-run. * run_skill: now honors frontmatter `od.mode`, `od.output_path_template`, `od.deck_grammar`. When mode=="deck" and template is set, the runner injects the path into intent, verifies the file exists post-run, and runs validate_deck with the skill's grammar. Validation result is returned via new SkillRunResult dataclass. (findings #4, #5) * `openkb deck new --skill <name>`: CLI flag accepts any installed deck skill (default openkb-deck-editorial). guizang and swiss now usable from the scripted CLI, not only freeform chat. (finding #1) * `/deck new --skill <name>` chat slash: same flag, parsed positionally alongside --critique. (finding #1) * tests/test_read_kb_file.py: 13 new tests mirroring test_write_kb_file for the read-side allow-list. Pins refusal of `.openkb/config.yaml`, `.env`, `raw/`, `..` traversal, absolute paths. (finding #6) * Generator deck branch: no longer calls validate_deck directly; just propagates run_deck_create's SkillRunResult.validation up. Validation is now a property of "this skill declared mode=deck", not of "this CLI path was taken". Existing tests updated: * tests/test_deck_validator.py: explicit grammar arg on Editorial- specific tests; added test_guizang_shape_passes_generic_mode + test_missing_cover_ignored_in_generic_mode to pin both modes. * tests/test_deck_creator.py: mocks return SkillRunResult; new test_run_deck_create_honors_skill_name_override for --skill flag. * tests/test_generator.py: deck dispatch test mocks SkillRunResult. Below-threshold findings deferred: * Generator if/else → registry (score 70) — works, just not extensible via plugin; future. * Iteration backup in chat freeform path (score 75) — needs write_kb_file hook; separate change. * run_skill / scan_local_skills / _handle_slash_critique direct tests (scores 60-70) — covered indirectly by integration; can add later. Regression: 538 tests pass (was 523 pre-fix; net +15 = 13 new read_kb_file tests + 2 new validator-mode tests).
1 parent 708baa3 commit 63b62c2

11 files changed

Lines changed: 645 additions & 188 deletions

File tree

openkb/agent/chat.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
" /lint Lint the knowledge base\n"
6161
" /add <path> Add a document or directory to the knowledge base\n"
6262
' /skill new <name> "<intent>" Compile a skill from the wiki\n'
63-
' /deck new [--critique] <name> "<intent>" Generate an HTML deck from the wiki\n'
63+
' /deck new [--critique] [--skill <name>] <name> "<intent>" Generate an HTML deck from the wiki\n'
6464
" /critique <path-to-html> Run html-critic skill on a file (CSS bugs, layout, self-containment)\n"
6565
" /help Show this"
6666
)
@@ -606,20 +606,29 @@ async def _handle_slash_deck(arg: str, kb_dir: Path, style: Style) -> None:
606606
_fmt(style, ("class:error", f"Unknown deck subcommand: {sub}. Try /deck new.\n"))
607607
return
608608

609-
# Parse optional --critique flag (anywhere among the remaining tokens
610-
# is fine, but typically right after `new`).
609+
# Parse optional --critique flag and --skill <name> option. Both can
610+
# appear anywhere among the remaining tokens.
611611
rest = parts[1:]
612612
critique = False
613+
skill_name: str | None = None
613614
filtered: list[str] = []
614-
for tok in rest:
615+
i = 0
616+
while i < len(rest):
617+
tok = rest[i]
615618
if tok == "--critique":
616619
critique = True
620+
elif tok == "--skill" and i + 1 < len(rest):
621+
skill_name = rest[i + 1]
622+
i += 1
623+
elif tok.startswith("--skill="):
624+
skill_name = tok.split("=", 1)[1]
617625
else:
618626
filtered.append(tok)
627+
i += 1
619628

620629
if len(filtered) < 2:
621630
_fmt(style, ("class:error",
622-
"Usage: /deck new [--critique] <name> \"<intent>\"\n"))
631+
"Usage: /deck new [--critique] [--skill <skill>] <name> \"<intent>\"\n"))
623632
return
624633

625634
name = filtered[0]
@@ -650,14 +659,19 @@ async def _handle_slash_deck(arg: str, kb_dir: Path, style: Style) -> None:
650659
model = config.get("model", DEFAULT_CONFIG["model"])
651660

652661
from openkb.skill.generator import Generator
653-
_fmt(style, ("class:slash.help", f"Generating deck '{name}'...\n"))
662+
skill_label = skill_name if skill_name else "openkb-deck-editorial (default)"
663+
_fmt(
664+
style,
665+
("class:slash.help", f"Generating deck '{name}' via skill {skill_label}...\n"),
666+
)
654667
gen = Generator(
655668
target_type="deck",
656669
name=name,
657670
intent=intent,
658671
kb_dir=kb_dir,
659672
model=model,
660673
critique=critique,
674+
skill_name=skill_name,
661675
)
662676
try:
663677
await gen.run()

openkb/agent/skill_runner.py

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,30 @@
44
agent instructions) is loaded by ``run_skill``, which builds an Agent
55
whose ``instructions`` are that body. The agent gets the standard wiki
66
read-tool set plus a constrained ``write_file`` tool scoped to
7-
``wiki/explorations/**`` and ``output/**``.
7+
``wiki/explorations/**`` and ``output/**``, and ``read_output_or_skill_file``
8+
for inspecting prior artifacts.
89
910
This decouples generators from hard-coded prompts. ``openkb deck new`` /
1011
``openkb skill new`` / any future ``openkb <type> new`` command becomes a
1112
two-line wrapper around ``run_skill(skill_name=..., intent=...)``.
13+
14+
Skill frontmatter that ``run_skill`` honours (all optional; under the
15+
top-level ``od:`` key):
16+
17+
* ``mode`` — the artifact type. ``"deck"`` triggers deck-specific
18+
post-run handling (output-path templating + validation). Unknown modes
19+
are accepted; they just don't trigger extra hooks.
20+
* ``output_path_template`` — a path string with ``{slug}`` placeholder,
21+
relative to KB root. When set, ``run_skill`` injects the resolved path
22+
into the agent's intent and verifies the file exists post-run.
23+
* ``deck_grammar`` — passed to :func:`openkb.deck.validator.validate_deck`
24+
when ``mode == "deck"``. See that module for the contract.
1225
"""
1326
from __future__ import annotations
1427

28+
from dataclasses import dataclass, field
1529
from pathlib import Path
16-
from typing import Optional
30+
from typing import Any, Optional
1731

1832
from agents import Agent, Runner, function_tool
1933

@@ -30,6 +44,23 @@ class SkillNotFoundError(RuntimeError):
3044
"""Raised when the requested skill can't be located in any skill root."""
3145

3246

47+
@dataclass
48+
class SkillRunResult:
49+
"""Outcome of a :func:`run_skill` call.
50+
51+
``validation`` is populated only when the skill declared a
52+
deck-mode artifact and the post-run validator was therefore invoked;
53+
otherwise ``None``. ``output_path`` is the KB-relative path the
54+
runner enforced via ``output_path_template``, or ``None`` if the
55+
skill left output placement to itself.
56+
"""
57+
58+
skill_name: str
59+
output_path: Optional[Path] = None
60+
validation: Optional[Any] = None # openkb.deck.validator.ValidationResult
61+
metadata: dict = field(default_factory=dict) # skill's ``od:`` block
62+
63+
3364
async def run_skill(
3465
*,
3566
skill_name: str,
@@ -39,14 +70,23 @@ async def run_skill(
3970
language: str = "en",
4071
max_turns: int = MAX_TURNS,
4172
seed: Optional[str] = None,
73+
slug: Optional[str] = None,
4274
extra_skill_roots: tuple[str | Path, ...] = (),
43-
) -> None:
44-
"""Load skill ``skill_name`` and run it as an agent with ``intent``.
75+
) -> SkillRunResult:
76+
"""Load ``skill_name`` and run it as an agent with ``intent``.
77+
78+
When the skill's frontmatter declares ``od.mode == "deck"`` and
79+
``od.output_path_template`` is set, ``run_skill``:
80+
81+
1. Templates the path with the provided ``slug``.
82+
2. Adds a "Write to: <path>" line to the agent's intent so the
83+
skill knows the expected destination.
84+
3. After the agent run, checks the file exists and (if the skill
85+
declared ``od.deck_grammar``) runs ``validate_deck`` against it.
4586
4687
Args:
4788
skill_name: Name (frontmatter ``name:``) of the skill to invoke.
48-
intent: Natural-language brief for what to produce. Appended to
49-
the skill body as a "## User intent" section.
89+
intent: Natural-language brief for what to produce.
5090
kb_dir: KB root. Used both for skill discovery and for the
5191
agent's wiki read-tools / write-file scoping.
5292
model: LiteLLM-formatted model string from KB config.
@@ -55,13 +95,21 @@ async def run_skill(
5595
max_turns: Hard cap on agent loop iterations.
5696
seed: Optional kick-off user message. Defaults to a short nudge
5797
that points the agent at its own instructions.
98+
slug: Required when the skill declares
99+
``od.output_path_template`` (it substitutes ``{slug}``).
100+
Otherwise ignored.
58101
extra_skill_roots: Additional directories to scan beyond the
59102
built-in ``<kb>/skills``, ``~/.openkb/skills``,
60103
``~/.claude/skills``.
61104
105+
Returns:
106+
A :class:`SkillRunResult` carrying the resolved output path (if
107+
any) and the validation result (for deck-mode skills).
108+
62109
Raises:
63110
SkillNotFoundError: if no skill with ``skill_name`` is found.
64-
RuntimeError: if the agent run fails (turn-cap, model error).
111+
RuntimeError: on turn-cap, model error, or missing
112+
output file after a templated-path run.
65113
"""
66114
skills = scan_local_skills(kb_dir, extra_roots=extra_skill_roots)
67115
match = next((s for s in skills if s["name"] == skill_name), None)
@@ -74,7 +122,19 @@ async def run_skill(
74122
)
75123

76124
skill_md = Path(match["path"]) / "SKILL.md"
77-
_meta, body = _parse_frontmatter(skill_md.read_text(encoding="utf-8"))
125+
meta, body = _parse_frontmatter(skill_md.read_text(encoding="utf-8"))
126+
od_meta: dict = (meta.get("od") or {}) if isinstance(meta, dict) else {}
127+
128+
# Resolve output path if the skill templated one.
129+
output_path: Optional[Path] = None
130+
template = od_meta.get("output_path_template")
131+
if template and slug:
132+
rel = template.format(slug=slug)
133+
output_path = (kb_dir / rel).resolve()
134+
intent = (
135+
f"Output file (write the artifact here, full file in one "
136+
f"write_file call): {rel}\n\n{intent}"
137+
)
78138

79139
wiki_root = str(kb_dir / "wiki")
80140
kb_root = str(kb_dir)
@@ -131,3 +191,27 @@ def read_output_or_skill_file(path: str) -> str:
131191
f"finishing. The intent may be too broad or the wiki too large; "
132192
f"try a tighter intent or split into smaller skills."
133193
) from exc
194+
195+
result = SkillRunResult(
196+
skill_name=skill_name,
197+
output_path=output_path,
198+
metadata=od_meta if isinstance(od_meta, dict) else {},
199+
)
200+
201+
# Post-run hooks driven by skill frontmatter.
202+
if output_path is not None and not output_path.is_file():
203+
raise RuntimeError(
204+
f"Skill {skill_name!r} finished but did not write the expected "
205+
f"output file at {output_path}. The skill is either misconfigured "
206+
f"or the wiki lacks content matching the intent."
207+
)
208+
209+
if od_meta.get("mode") == "deck" and output_path is not None:
210+
# Lazy import — skill_runner shouldn't pull in deck-specific code
211+
# at import time, only when actually running a deck skill.
212+
from openkb.deck.validator import DeckGrammar, validate_deck
213+
214+
grammar: Optional[DeckGrammar] = od_meta.get("deck_grammar")
215+
result.validation = validate_deck(output_path.parent, grammar=grammar)
216+
217+
return result

openkb/cli.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1934,8 +1934,18 @@ def deck():
19341934
is_flag=True, default=False,
19351935
help="Opt-in second-pass review via a critic agent (slower, higher quality).",
19361936
)
1937+
@click.option(
1938+
"--skill", "skill_name",
1939+
metavar="SKILL_NAME",
1940+
default=None,
1941+
help=(
1942+
"Which deck skill to use. Defaults to 'openkb-deck-editorial' "
1943+
"(the built-in). Pass e.g. 'deck-guizang-editorial' to route to "
1944+
"a third-party skill installed under ~/.openkb/skills/."
1945+
),
1946+
)
19371947
@click.pass_context
1938-
def deck_new(ctx, name, intent, yes_flag, critique_flag):
1948+
def deck_new(ctx, name, intent, yes_flag, critique_flag, skill_name):
19391949
"""Generate a new HTML deck from this KB's wiki.
19401950
19411951
NAME is a kebab-case slug used for the output directory.
@@ -1945,6 +1955,7 @@ def deck_new(ctx, name, intent, yes_flag, critique_flag):
19451955
19461956
openkb deck new transformers-pitch "Explain attention to engineers"
19471957
openkb deck new transformers-pitch "Explain attention to engineers" --critique
1958+
openkb deck new transformers-pitch "..." --skill deck-guizang-editorial
19481959
"""
19491960
kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override"))
19501961
if kb_dir is None:
@@ -2007,14 +2018,16 @@ def deck_new(ctx, name, intent, yes_flag, critique_flag):
20072018

20082019
# Run the generator.
20092020
from openkb.skill.generator import Generator
2010-
click.echo(f"Generating deck '{name}'...")
2021+
skill_label = skill_name if skill_name else "openkb-deck-editorial (default)"
2022+
click.echo(f"Generating deck '{name}' via skill {skill_label}...")
20112023
gen = Generator(
20122024
target_type="deck",
20132025
name=name,
20142026
intent=intent,
20152027
kb_dir=kb_dir,
20162028
model=model,
20172029
critique=critique_flag,
2030+
skill_name=skill_name,
20182031
)
20192032
try:
20202033
asyncio.run(gen.run())

0 commit comments

Comments
 (0)