Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
6145871
feat(catalog): not-a-skill-no-frontmatter with a frontmatter hint
napetrov Nov 9, 2026
dfc3811
fix(catalog): frontmatter after a blank line is a broken skill, not p…
napetrov Nov 9, 2026
337cc4e
fix(catalog): annotate the paragraph list in no_frontmatter for mypy
napetrov Nov 9, 2026
14b3b3e
fix(catalog): a shorter fence run does not end a fence in the hint
napetrov Nov 10, 2026
7a679d0
fix(catalog): quote the hint's name so YAML reads it as text
napetrov Nov 10, 2026
6546d14
Merge branch main into feat/not-a-skill-no-frontmatter
napetrov Nov 10, 2026
a6d149f
Address review: take the hint paragraph after the first H1
napetrov Nov 10, 2026
d49c7d0
Address review: the hint names a skill below the root by its directory
napetrov Nov 10, 2026
bef9b4d
Address review: an indented frontmatter is a broken skill, not plain …
napetrov Nov 10, 2026
ef01bd7
Address review: the docstrings say a plain-Markdown SKILL.md gets a r…
napetrov Nov 10, 2026
809a57d
Address review: drop the unreachable blockquote branch of the hint pa…
napetrov Nov 10, 2026
a390071
Address review: indent a multi-line message under its catalog row
napetrov Nov 10, 2026
b5deabe
Address review: the docs say which name the hint takes
napetrov Nov 10, 2026
073e5be
Merge branch 'main' of https://github.com/intel/dokimi into feat/not-…
napetrov Nov 10, 2026
07e9c5b
Self-review: keep the hint valid YAML; drop a duplicate blank-line test
napetrov Nov 10, 2026
2774571
Address review: the docs say which name the hint takes
napetrov Nov 10, 2026
1e62209
catalog check: the --ignore refusal for not-a-skill-no-frontmatter na…
napetrov Nov 10, 2026
7944394
Address review: a tab after the # is an H1 too
napetrov Nov 11, 2026
54bdede
Merge branch 'main' of https://github.com/intel/dokimi into feat/not-…
napetrov Nov 11, 2026
2fc0bf6
Name not-a-skill-no-frontmatter among the refused --ignore codes
antonwolfy Nov 11, 2026
e8ee559
Skip multi-line HTML comments in the hint, and give an empty file no …
antonwolfy Nov 11, 2026
9f02a72
Leave a frontmatter block after another line to the loader's error
antonwolfy Nov 11, 2026
9539b14
Pin the root name, the plain-Markdown count, an empty file and a BOM
antonwolfy Nov 11, 2026
56c6f9b
Pin the late-frontmatter window and a BOM-only file in tests
antonwolfy Nov 11, 2026
05d06ec
Merge branch 'main' of https://github.com/intel/dokimi into feat/not-…
napetrov Nov 12, 2026
40e0ca1
Address review: a fenced frontmatter example is not misplaced frontma…
napetrov Nov 12, 2026
eed07be
Thread: PRR_kwDOVBg7g88AAAABXXuAkA: already fixed in 56c6f9b
napetrov Nov 12, 2026
29cb3e6
Thread: PRR_kwDOVBg7g88AAAABXYe8CA: already fixed in 9539b14
napetrov Nov 12, 2026
dec4522
Thread: PRR_kwDOVBg7g88AAAABXZISQA: already fixed in 40e0ca1fe
napetrov Nov 12, 2026
3bd466f
fix(external): a quoted frontmatter example is not misplaced frontmatter
vlad-perevezentsev Nov 12, 2026
15534b1
Merge branch 'main' of https://github.com/intel/dokimi into feat/not-…
napetrov Nov 13, 2026
4052992
Address review: a SKILL.md that opens with a --- rule and prose gets …
napetrov Nov 13, 2026
6b4c7a9
Address review: an H1 inside an HTML comment is not the title
napetrov Nov 13, 2026
e219942
Address review: a resumed finding with no code is not plain Markdown
napetrov Nov 13, 2026
1be5501
Address review: a setext H1 is the title
napetrov Nov 13, 2026
8ebd4a0
Address review: an indented code block after the H1 is not the descri…
napetrov Nov 13, 2026
c071ff5
Address review: pin the 64-character name limit for the H1 and the di…
napetrov Nov 13, 2026
44386da
Address review: the root name falls back to the directory when the H1…
napetrov Nov 13, 2026
3b1b878
Address review: a --- block with a YAML key is frontmatter, not a rule
napetrov Nov 13, 2026
8e1f2d5
Merge branch 'main' of https://github.com/intel/dokimi into feat/not-…
napetrov Nov 13, 2026
e773c3c
fix(catalog): quoted, flow and list keys open frontmatter, not prose
xaleryb Nov 13, 2026
16b5d9f
fix(catalog): a Word: prose line does not make an unclosed rule front…
xaleryb Nov 13, 2026
2fed3f6
Merge branch 'feat/not-a-skill-no-frontmatter' of https://github.com/…
napetrov Nov 13, 2026
b7fa886
Thread: IC_kwDOVBg7g88AAAABmDJTQw: already fixed in 16b5d9f6a
napetrov Nov 13, 2026
4c63e3e
Merge branch 'main' into feat/not-a-skill-no-frontmatter
xaleryb Nov 14, 2026
58a6fdd
test(catalog): drop a note reference the repository does not define
xaleryb Nov 14, 2026
364d8da
Merge branch 'main' into feat/not-a-skill-no-frontmatter
xaleryb Nov 14, 2026
da1f8fe
Merge branch 'main' of https://github.com/intel/dokimi into feat/not-…
napetrov Nov 14, 2026
63ff037
Address review: a frontmatter block of blank lines is a broken skill
napetrov Nov 14, 2026
3b25b70
Address review: HTML comments are taken out of the hint text
napetrov Nov 14, 2026
a8c2aa1
Address review: pin the classifier branches and the late-frontmatter …
napetrov Nov 14, 2026
7c4a02f
Keep a comment marker that does not open a comment in the hint
napetrov Nov 14, 2026
0077698
Keep a comment or code span inside its paragraph in the hint
napetrov Nov 14, 2026
fccb593
Bound the comment and code-span search in the hint
napetrov Nov 14, 2026
c4f27c6
Make the hint's code-span search linear in a backtick run
napetrov Nov 14, 2026
fcd55c7
Document the late-frontmatter window in external-content.md
antonwolfy Nov 14, 2026
d1a8ea0
Pin the comment and code-span branches of the hint in tests
antonwolfy Nov 14, 2026
c70579a
fix(catalog): keep the loader's error for odd frontmatter blocks
vchamarthi Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- **`catalog check` says how to turn a plain-Markdown `SKILL.md` into a skill.** A `SKILL.md` with no frontmatter still fails to load, and now has one finding, `not-a-skill-no-frontmatter`, whose message holds a `name`/`description` block made from the file's H1 and first paragraph. The summary line counts these apart from the other errors (`3 error, of them 2 plain Markdown with no frontmatter`), so a scan of a large corpus can tell prose files from broken skills.
16 changes: 15 additions & 1 deletion dokimi/docs/external-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,19 @@ order) is the original, the copy the `skill-catalog:` arm offers; each other cop
names it. Warnings never change the exit code. `disable-model-invocation: true` is printed as a
`note`, because the model is never offered that skill.

A `SKILL.md` with no frontmatter (plain Markdown, often a README under the
wrong name) does not load. Its finding, `not-a-skill-no-frontmatter`, holds a
frontmatter block to start from. The description is the paragraph after the
file's H1 (the first paragraph when there is no H1). The name is the
directory's below the scanned root, where the loader checks it against the
directory; at the root, or when the directory name is not a valid name, it is
the H1 in kebab-case (ASCII letters and digits, at most 64 characters), or the
directory name when the H1 has none. The summary line counts these apart from the other errors
(`3 error, of them 2 plain Markdown with no frontmatter`).
Comment thread
antonwolfy marked this conversation as resolved.
Resolved
A `---` block with a `name:` or `description:` line in the first 20 lines, or that opens with a YAML key
(any, `title:` included), keeps the loader's error and gets no hint. So does a block of blank lines between two `---` lines.
A block that opens with a `[` or `{` that is not a Markdown link also keeps the loader's error. A `---` rule, or a block with no YAML key and no such opener, is plain Markdown and gets this finding.

The scan also checks the bundle and the description. A relative Markdown link
to a file the skill does not have is a `warn` (`link-missing`), and says when the
file exists from the catalog root instead. Example links such as `[Title](URL)`
Expand Down Expand Up @@ -404,7 +417,8 @@ A large catalog is easier to scan one skill at a time:
they are not printed, and they do not set the state or the `--strict` exit. The
codes are the `code` of each finding in the JSON output (`name-mismatch`,
`description-no-when`, `description-when-not-checked`, ...); an unknown code is a usage error. `frontmatter-parse`
is refused (a skill that does not load always fails), and so is `unreadable` (a
and `not-a-skill-no-frontmatter` are refused (a skill that does not load always
fails), and so is `unreadable` (a
`SKILL.md` the OS will not read fails the scan too), and `--kind tasks`.
- `--jsonl PATH` writes one JSON row per skill as it is reviewed and prints only
the counts. `--jobs N` reviews N skills in parallel. `--resume` keeps the rows
Expand Down
12 changes: 10 additions & 2 deletions dokimi/dokimi/commands/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
import json
import sys
from pathlib import Path
from dokimi.commands.catalog_scan import not_checked, ok_text, print_skill_row, skill_row, unrestricted_and_risky
from dokimi.commands.catalog_scan import (
is_plain_markdown, not_checked, ok_text, plain_note, print_skill_row, skill_row,
unrestricted_and_risky,
)
from dokimi.utils import InputError


Expand Down Expand Up @@ -52,6 +55,7 @@ def _scan_skills(args: argparse.Namespace, root: Path, report: dict, as_json: bo
if only is not None and only not in {entry.name for entry in skills}:
raise InputError(f"no skill named {only!r} under {root}")
counts = {"ok": 0, "warn": 0, "error": 0}
plain = 0
unchecked = 0
failed = False
ignore = frozenset(args.ignore or ())
Expand All @@ -60,6 +64,7 @@ def _scan_skills(args: argparse.Namespace, root: Path, report: dict, as_json: bo
continue
row = skill_row(skill, review, nested_in, ignore=ignore)
counts[row["state"]] += 1
plain += is_plain_markdown(row)
unchecked += not_checked(row)
report["skills"].append(row)
# A load error, or a SKILL.md the OS will not read, always fails; a spec error, or unrestricted Bash with a risky command, only with --strict.
Expand All @@ -70,7 +75,7 @@ def _scan_skills(args: argparse.Namespace, root: Path, report: dict, as_json: bo
shown = f"{sum(counts.values())} of {len(skills)}" if only is not None else f"{len(skills)}"
print(
f"# skills ({shown} declared under {root}; "
f"{ok_text(counts['ok'], unchecked)}, {counts['warn']} warn, {counts['error']} error)"
f"{ok_text(counts['ok'], unchecked)}, {counts['warn']} warn, {counts['error']} error{plain_note(plain)})"
)
for row in report["skills"]:
print_skill_row(row)
Expand Down Expand Up @@ -173,6 +178,9 @@ def _check_scan_flags(args: argparse.Namespace) -> None:
# The skill did not load, so its state is error whatever is ignored: refuse rather than hide the cause.
raise InputError("--ignore frontmatter-parse: a skill whose frontmatter is not valid YAML does not load, "
"and a load error always fails")
if "not-a-skill-no-frontmatter" in (args.ignore or ()):
raise InputError("--ignore not-a-skill-no-frontmatter: a SKILL.md with no frontmatter does not load, "
"and a load error always fails; a narrower --root leaves it out")
if "unreadable" in (args.ignore or ()):
# The whole-catalog exit reads the unfiltered findings, --per-skill the filtered rows: ignoring it splits them.
raise InputError("--ignore unreadable: a SKILL.md the OS will not read always fails the scan, as a load error does")
Expand Down
29 changes: 24 additions & 5 deletions dokimi/dokimi/commands/catalog_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def skill_row(skill, review, nested_in: Optional[str] = None, outputs: Iterable[
ignore: frozenset = frozenset()) -> dict:
"""One skill of the scan report. ``state`` is ``error`` for a load error or an error finding,
``warn`` for a warning finding only, else ``ok``. A skill that did not load has a review only
when its frontmatter is broken YAML, and that review's finding is its only finding.
when its SKILL.md is plain Markdown or its frontmatter is broken YAML, and that review's finding is its only finding.
``nested_in`` is the skill whose directory holds this one (None when it is not nested).
``outputs`` are the scan's own files, left out of ``skill_sha256``. ``ignore`` drops the
findings with those codes before the state is taken; ``ignored`` records the codes in effect."""
Expand All @@ -52,6 +52,18 @@ def skill_row(skill, review, nested_in: Optional[str] = None, outputs: Iterable[
}


def is_plain_markdown(row: dict) -> bool:
"""Whether the row is a ``SKILL.md`` with no frontmatter: plain Markdown, not a broken skill."""
from dokimi.external.no_frontmatter import NO_FRONTMATTER

return any(f.get("code") == NO_FRONTMATTER for f in row["findings"])


def plain_note(plain: int) -> str:
"""The summary's note on the errors that are plain Markdown with no frontmatter; empty for none."""
return f", of them {plain} plain Markdown with no frontmatter" if plain else ""


def skill_dir_digest(entry, outputs: Iterable[Path] = ()) -> Optional[str]:
"""``tree_digest`` of the entry's skill directory by the bytes and mode bits on disk, with no git call.

Expand Down Expand Up @@ -80,6 +92,11 @@ def _outputs(jsonl: Optional[Path]) -> tuple[Path, ...]:
return (out, out.with_name(out.name + ".part"))


def _continued(message: str) -> str:
"""``message`` with its continuation lines indented under the row, so a multi-line message stays in it."""
return message.replace("\n", "\n ")


def not_checked(row: dict) -> bool:
"""Whether an ``ok`` row is only ok because the check did not read it (a ``description-when-not-checked`` note)."""
return row["state"] == "ok" and any(f["code"] == "description-when-not-checked" for f in row["findings"])
Expand All @@ -92,7 +109,7 @@ def ok_text(ok: int, unchecked: int) -> str:

def print_skill_row(row: dict) -> None:
if row["error"] is not None:
state = f"ERROR: {row['error']}"
state = f"ERROR: {_continued(row['error'])}"
elif row["state"] == "ok" and not not_checked(row):
state = "ok"
elif row["state"] == "ok":
Expand All @@ -101,10 +118,10 @@ def print_skill_row(row: dict) -> None:
state = f"note: {note['message']}"
else:
worst = next(f for f in row["findings"] if f["level"] == row["state"])
state = f"{'ERROR' if row['state'] == 'error' else 'warn'}: {worst['message']}"
state = f"{'ERROR' if row['state'] == 'error' else 'warn'}: {_continued(worst['message'])}"
print(f"{row['name']}\t{row['manifest_status'] or '-'}\t{row['path'] or '-'}\t{state}")
for f in row["findings"]:
print(f" {f['level']} {f['code']}: {f['message']}")
print(f" {f['level']} {f['code']}: {_continued(f['message'])}")
if row["disable_model_invocation"]:
print(" note disable-model-invocation: true (the model is never offered this skill)")
if row["nested_in"] is not None:
Expand Down Expand Up @@ -193,12 +210,14 @@ class _Tally:
def __init__(self, strict: bool) -> None:
self.strict = strict
self.counts = {"ok": 0, "warn": 0, "error": 0}
self.plain = 0 # error rows that are plain Markdown with no frontmatter (is_plain_markdown)
self.not_checked = 0
self.failed = False
self.reasons: list[str] = []

def add(self, row: dict) -> None:
self.counts[row["state"]] += 1
self.plain += is_plain_markdown(row)
self.not_checked += not_checked(row)
# A SKILL.md the OS will not read after it loaded has no row error, only its unreadable finding: it fails
# the scan as a load error does, as the whole-catalog path decides it (catalog.py).
Expand Down Expand Up @@ -315,7 +334,7 @@ def _print_text(root: Path, declared: int, tally: _Tally, resumed: int,
counts = tally.counts
where = f"; {resumed} resumed from {jsonl}" if jsonl is not None and resumed else ""
print(f"# skills ({declared} declared under {root}; {ok_text(counts['ok'], tally.not_checked)}, {counts['warn']} warn, "
f"{counts['error']} error{where})")
f"{counts['error']} error{plain_note(tally.plain)}{where})")
if jsonl is not None:
print(f"rows written to {jsonl}")
else:
Expand Down
3 changes: 2 additions & 1 deletion dokimi/dokimi/external/catalog_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ def review_skill_dir(path: Path | str) -> SkillReview:
*path* is ``link-escapes-skill``, as when the skill is installed alone. Every SKILL.md the scan finds
below *path* is loaded to find its own, and the other bodies are discarded (eager; lazy loading is #434).

A skill that does not load gets one ``error`` finding (``not-loaded``) with the loader's
A skill that does not load gets one ``error`` finding: its review's own (``not-a-skill-no-frontmatter``
for plain Markdown, ``frontmatter-parse`` for broken YAML), else ``not-loaded`` with the loader's
message. Raises ``ValueError`` when *path* is not a directory or holds no ``SKILL.md``.
"""
skill_dir = Path(path)
Expand Down
98 changes: 98 additions & 0 deletions dokimi/dokimi/external/no_frontmatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

"""A ``SKILL.md`` with no frontmatter: plain Markdown, not a broken skill.

Such a file does not load (the loader's error says "no frontmatter"). :mod:`dokimi.external.validate`
gives it one ``error`` finding, ``not-a-skill-no-frontmatter``, whose message holds a frontmatter block
made from the file's H1 and first paragraph, and ``catalog check`` counts these apart from the other
load errors in its summary. In the GitSkills corpus they are 165K of 1.88M distinct ``SKILL.md`` files,
141K of them starting with a ``#`` heading.
"""

from __future__ import annotations

import json
import re

from dokimi.external.skill_markdown import outside_fences

NO_FRONTMATTER = "not-a-skill-no-frontmatter"
NAME_MAX = 64
SPEC_NAME = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") # the spec's name pattern (validate.NAME_PATTERN)
_NON_PRINTABLE = re.compile(r"[^\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]") # PyYAML's reader
DESCRIPTION_HINT_MAX = 300
SETEXT_UNDERLINE_RE = re.compile(r"=+") # the underline of a setext H1 (a --- one is a rule or an H2)
# Code spans and inline comments stay in their paragraph and within 1024 characters (bounded: linear time).
# A comment that opens a line may run over blank lines; unclosed, it runs to the end.
_COMMENT_RE = re.compile(
r"(?<!`)(`+)(?!`)(?:(?!\n[ \t]{0,1024}\n).){1,1024}?(?<!`)\1(?!`)|^[ \t]*<!--(?!.*?-->).*\Z|^[ \t]*<!--.*?-->|<!--(?:(?!\n[ \t]{0,1024}\n).){0,1024}?-->",
re.S | re.M,
)


def no_frontmatter_message(lines: list[str], dir_name: str, at_root: bool = False) -> str:
"""The ``not-a-skill-no-frontmatter`` message, with a frontmatter block made from the H1 and the first paragraph.

Below the scanned root the name is the directory's when that is a valid spec name, since the
loader's ``name-mismatch`` check compares the name with it; otherwise (and at the root) it is the
H1 in kebab-case (ASCII letters and digits; the directory name when the H1 has none, or there is
no H1). The description is the first paragraph after the H1, cut at a word near 300 characters.
The block is a start: the description still needs a "when" clause.
"""
# CommonMark fences: a shorter run or another marker does not close one
raw = _without_comments("\n".join(line.lstrip("\ufeff") for line in outside_fences("\n".join(lines))))
body = [line.strip() for line in raw]
title = ""
start = 0 # the paragraph is the one after the first H1: an intro or a badge above it is not taken
for i, stripped in enumerate(body):
Comment thread
napetrov marked this conversation as resolved.
Resolved
if stripped[:2] in ("# ", "#\t"): # CommonMark: a space or a tab after the #
Comment thread
napetrov marked this conversation as resolved.
Resolved
title = stripped[2:].strip()
start = i + 1
break
if stripped and i + 1 < len(body) and SETEXT_UNDERLINE_RE.fullmatch(body[i + 1]): # ``Title`` over ``===``
title = stripped
start = i + 2
break
paragraph = _first_paragraph(raw[start:])
if not at_root and SPEC_NAME.fullmatch(dir_name) and len(dir_name) <= NAME_MAX:
name = dir_name # validate.NAME_PATTERN, as the loader's name-mismatch check reads it
else:
name = _kebab(title) or _kebab(dir_name) or "my-skill"
description = _NON_PRINTABLE.sub("", " ".join(paragraph)) # what PyYAML refuses in a plain or quoted scalar
if len(description) > DESCRIPTION_HINT_MAX:
description = description[:DESCRIPTION_HINT_MAX].rsplit(" ", 1)[0] + " ..."
description = description or f"What {name} does. Use when ..."
# Always quoted: YAML reads a name such as "2048", "yes" or "2026-02-30" as a number, a bool or a date.
name_text = json.dumps(name)
block = f"---\nname: {name_text}\ndescription: {json.dumps(description, ensure_ascii=False)}\n---"
return (
"plain Markdown with no frontmatter: no harness offers it as a skill. To make it one, start "
f"the file with (then add when to use it to the description):\n{block}"
)


def _without_comments(text: str) -> list[str]:
"""The lines of ``text`` with its HTML comments taken out; text around a comment on its line is kept."""
return _COMMENT_RE.sub(lambda m: m.group(0) if m.group(0).startswith("`") else "", text).split("\n")


def _first_paragraph(raw: list[str]) -> list[str]:
"""The lines of the first paragraph of ``raw`` (unstripped), skipping blank, heading, table and HTML lines,
and an indented code block before it (four spaces or a tab)."""
paragraph: list[str] = []
for line in raw:
stripped = line.strip()
if not paragraph and line.startswith(("\t", " ")) and stripped:
continue # an indented code block is not prose
if not stripped or stripped.startswith(("#", "|", "<", "!", "---")):
if paragraph:
break
continue
paragraph.append(stripped) # outside_fences has taken the quote markers off already
return paragraph


def _kebab(text: str) -> str:
"""``text`` as a spec name: ASCII lowercase letters and digits joined by single hyphens, at most 64 characters."""
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:NAME_MAX].strip("-")
Comment thread
napetrov marked this conversation as resolved.
Resolved
Loading
Loading