From 78457e26b251b582f169b42b574a4b9bc025fd11 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 18:17:31 -0700 Subject: [PATCH 01/22] feat(repo): guidance:format=xml and guidance:as-skill arms Two guidance parts for `repo run --arm`, to settle format and packaging debates with data: the same guidance content in another shape, compared per model. - guidance:format=xml renders each guidance file as a element with one
per Markdown heading. The rendering must restore to the original file exactly, or the cell's preparation fails. - guidance:as-skill removes the guidance files, as guidance:none does, and packages their text into .claude/skills/-guidance/SKILL.md; the skill is recorded in `skills`, so usage is measured like any skill. guidance:format=md is refused (it is guidance:raw). POML is not offered: its documented components have no heading tag. Signed-off-by: Nikolay Petrov --- ...31_120000_napetrov_guidance-format-arms.md | 3 + dokimi/docs/benchmarking-and-comparison.md | 33 +++ dokimi/docs/repo-evaluation-contract.md | 5 +- dokimi/dokimi/repos/guidance_format.py | 83 +++++++ dokimi/dokimi/repos/workspace.py | 77 +++++- dokimi/tests/test_repo_guidance_format.py | 227 ++++++++++++++++++ 6 files changed, 420 insertions(+), 8 deletions(-) create mode 100644 dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md create mode 100644 dokimi/dokimi/repos/guidance_format.py create mode 100644 dokimi/tests/test_repo_guidance_format.py diff --git a/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md b/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md new file mode 100644 index 000000000..81b329bf8 --- /dev/null +++ b/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md @@ -0,0 +1,3 @@ +### Added + +- **`repo run` arms `guidance:format=xml` and `guidance:as-skill`** — the guidance files of the base, rendered as XML tags (each Markdown heading becomes a `
` element, checked to restore to the original file exactly), or moved into one skill directory (`.claude/skills/-guidance/SKILL.md`). Both compare the same guidance content in another shape under the same model; `guidance:format=md` is refused as a duplicate of `guidance:raw`, and POML is not offered yet. diff --git a/dokimi/docs/benchmarking-and-comparison.md b/dokimi/docs/benchmarking-and-comparison.md index db3f19ccd..fc56cee9f 100644 --- a/dokimi/docs/benchmarking-and-comparison.md +++ b/dokimi/docs/benchmarking-and-comparison.md @@ -301,6 +301,39 @@ both per row. Every cell also records `cache_read_tokens` and `cache_write_tokens` with the input and output tokens, and `repo report --json` gives their means. +### Guidance formats and packaging + +Two guidance arms answer "does the same content work better in another shape?" +The model stays the same, so run them per model (one `--model` each) and compare +the cells the way the skill arms are compared above. + +- `guidance:format=xml`: every guidance file of the base, rendered as XML tags. + Each Markdown heading becomes `
`, closed before + the next heading of its level or above; `` wraps the file. No other + line changes. The rendering is checked before it is written: it must restore to + the original file exactly, so a file with a line that looks like one of these + tags (or a heading the restore cannot rebuild) fails the cell's preparation + rather than measuring altered content. A heading inside a code fence is body + text. A heading line that is not `#`…`######`, one space, then the title, with no + carriage return, is body text too: a file with CRLF line endings has no sections + at all, only the wrapper. `guidance:format=md` is `guidance:raw` + and is refused as a second name for it. POML is not offered yet: its documented + components (`role`, `task`, `example`, ...) have no heading tag, so a POML arm + needs a decision about which element a heading maps to. +- `guidance:as-skill`: the guidance files are removed (as `guidance:none` removes + them) and their text goes into `.claude/skills/-guidance/SKILL.md`, each + file under a `## File: ` line with its text verbatim. The frontmatter + description is one fixed sentence for every repository, so the skill is + selected on its description alone. Nested guidance files lose the directory + they applied to; the skill records its sha256 and appears in `skills` like any + installed skill, so `repo report` counts whether the agent loaded it. + +Reading the two together: `guidance:format=xml` against `guidance:raw` isolates +the markup, and `guidance:as-skill` against `guidance:raw` isolates the packaging +(always in context, or loaded on demand), with the fixed description a known +confound of the second. The arms do not rewrite the content, so a difference +comes from the format or the packaging, not from edited guidance. + ### Reading the significance tables Arms are compared **paired per question** against the one shared `baseline` diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index c2b873922..51e6c618b 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -293,7 +293,10 @@ belongs to the repository's grader and is passed through unread, except `grader` 3. `git init` and commit (no history, so fixes cannot leak) 4. apply `setup_patches` 5. apply the arm: `guidance:none`, `guidance:raw`, `guidance:bridge`, - `guidance:rev:` or `patch:` + `guidance:rev:`, `patch:`, `guidance:format=xml` (the guidance + files rendered as tagged sections, `dokimi/repos/guidance_format.py`) or + `guidance:as-skill` (the guidance files removed, their text packaged as one + skill, see [Guidance formats and packaging](benchmarking-and-comparison.md#guidance-formats-and-packaging)) 6. amend the commit 7. apply `review_patch` with `git am` under a neutral author 8. expire the reflog and prune unreachable objects: the diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py new file mode 100644 index 000000000..0945658ee --- /dev/null +++ b/dokimi/dokimi/repos/guidance_format.py @@ -0,0 +1,83 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Guidance rendered in another format: the ``guidance:format=`` arm (contract §3, step 5). + +``xml`` wraps a guidance file in a ```` element and turns each ATX heading into a +``
`` element, closed before the next heading of its level or +above. No other line changes, and :func:`restore` undoes the wrapping exactly. :func:`to_format` +refuses a file whose rendering does not restore to it (a body line that looks like one of the +tags, a heading the restore could not rebuild), so the arm never measures altered content. +Headings inside code fences are body text. +""" + +from __future__ import annotations + +import re + +#: The formats of ``guidance:format=``. Markdown is ``guidance:raw``, not a format of its own. +FORMATS = ("xml",) + +_ROOT, _ROOT_CLOSE, _CLOSE = "", "", "
" +_HEADING = re.compile(r"^(#{1,6}) (\S[^\r]*)$") # canonical only: one space, no CR; anything else is body text +_FENCE = re.compile(r"^\s{0,3}(```|~~~)") +_OPEN = re.compile(r'^
$') +_ESCAPES = {"&": "&", "<": "<", ">": ">", '"': """} +_UNESCAPES = {v: k for k, v in _ESCAPES.items()} + + +class FormatError(ValueError): + """A guidance file that cannot be rendered in the format, or a rendering that would not restore.""" + + +def to_format(text: str, fmt: str) -> str: + """``text`` (Markdown) rendered as ``fmt``; raises :class:`FormatError` unless :func:`restore` gives ``text``.""" + if fmt not in FORMATS: + raise FormatError(f"unknown guidance format {fmt!r} (known: {', '.join(FORMATS)})") + rendered = _render_xml(text) + if restore(rendered) != text: + raise FormatError(f"the {fmt} rendering would not restore to the file (a line of it looks like a tag)") + return rendered + + +def restore(rendered: str) -> str: + """The Markdown a rendering came from: the wrapper lines removed, each section tag back to its heading.""" + lines = rendered.split("\n") + if len(lines) < 2 or lines[0] != _ROOT or lines[-1] != _ROOT_CLOSE: + raise FormatError("not a guidance rendering: the root element is missing") + out = [] + for line in lines[1:-1]: + if line == _CLOSE: + continue + match = _OPEN.match(line) + out.append(f"{'#' * int(match.group(1))} {_unescape(match.group(2))}" if match else line) + return "\n".join(out) + + +def _render_xml(text: str) -> str: + out, fenced = [_ROOT], False + open_levels: list[int] = [] + for line in text.split("\n"): + heading = None if fenced else _HEADING.match(line) + if _FENCE.match(line): + fenced = not fenced + if heading is None: + out.append(line) + continue + level = len(heading.group(1)) + while open_levels and open_levels[-1] >= level: + out.append(_CLOSE) + open_levels.pop() + out.append(f'
') + open_levels.append(level) + out.extend([_CLOSE] * len(open_levels)) + out.append(_ROOT_CLOSE) + return "\n".join(out) + + +def _escape(title: str) -> str: + return re.sub(r'[&<>"]', lambda m: _ESCAPES[m.group()], title) + + +def _unescape(title: str) -> str: + return re.sub(r"&(?:amp|lt|gt|quot);", lambda m: _UNESCAPES[m.group()], title) diff --git a/dokimi/dokimi/repos/workspace.py b/dokimi/dokimi/repos/workspace.py index b5e0dba74..f7497de89 100644 --- a/dokimi/dokimi/repos/workspace.py +++ b/dokimi/dokimi/repos/workspace.py @@ -12,7 +12,8 @@ 3. ``git init`` and commit (no history, so an upstream fix cannot leak) 4. apply ``setup_patches`` 5. apply the arm (``guidance:none`` | ``guidance:raw`` | ``guidance:bridge`` | - ``guidance:rev:`` | ``patch:@``, see :class:`Arm`), then its + ``guidance:as-skill`` | ``guidance:format=`` | ``guidance:rev:`` | + ``patch:@``, see :class:`Arm`), then its skills into ``.claude/skills/`` (:mod:`.arms`) 6. amend the commit 7. apply ``review_patch`` with ``git am`` under a neutral author @@ -49,17 +50,22 @@ from dokimi import process -from .arms import ArmError, Skill, resolve_skills, tree_sha256 +from .arms import SKILLS_DIR, ArmError, Skill, resolve_skills, slug, tree_sha256 from .errors import PrepError +from .guidance_format import FORMATS, FormatError, to_format from .harness_profile import HarnessProfile from .manifest import RepoManifest, _is_within, match_glob from .source import GitTree, RepoSource, RepoTree, SourceError, cache_path, default_cache_dir, resolve_rev from .uptake import MCP_CONFIG, base_servers, mcp_servers -ARMS = ("guidance:none", "guidance:raw", "guidance:bridge") # the guidance parts -REV_ARM, PATCH_ARM = "guidance:rev:", "patch:" +ARMS = ("guidance:none", "guidance:raw", "guidance:bridge", "guidance:as-skill") # the guidance parts +#: The description of the skill ``guidance:as-skill`` packages the guidance into: the same for every repository. +GUIDANCE_SKILL_DESCRIPTION = ("Repository guidance for working in this codebase: its conventions, build and test " + "commands and rules. Load it before changing its code.") +REV_ARM, PATCH_ARM, FORMAT_ARM = "guidance:rev:", "patch:", "guidance:format=" #: Every form ``repo run --arm`` takes -ARM_FORMS = (*ARMS, f"{REV_ARM}", f"{PATCH_ARM}", "skill:", "skill-catalog:") +ARM_FORMS = (*ARMS, f"{REV_ARM}", f"{PATCH_ARM}", f"{FORMAT_ARM}<{'|'.join(FORMATS)}>", + "skill:", "skill-catalog:") SKILL_JOIN = "+" _SKILL_PREFIXES = ("skill:", "skill-catalog:") SNAPSHOT_AUTHOR = ("dokimi", "dokimi@example.invalid") @@ -139,13 +145,19 @@ def parse_arm(spec: str) -> Arm: def _starts_part(text: str) -> bool: - return text in ARMS or text.startswith((REV_ARM, PATCH_ARM, *_SKILL_PREFIXES)) + return text in ARMS or text.startswith((REV_ARM, PATCH_ARM, FORMAT_ARM, *_SKILL_PREFIXES)) def _parse_guidance(spec: str) -> Arm: - """A guidance part: ``guidance:none|raw|bridge``, ``guidance:rev:`` or ``patch:``.""" + """A guidance part: ``guidance:none|raw|bridge|as-skill``, ``guidance:format=``, + ``guidance:rev:`` or ``patch:``.""" if spec in ARMS: return Arm(spec) + if spec.startswith(FORMAT_ARM): + fmt = spec[len(FORMAT_ARM):] + if fmt not in FORMATS: # markdown is guidance:raw, a second name for it would be a duplicate arm + raise PrepError(f"arm {spec!r}: {fmt!r} is not a guidance format (known: {', '.join(FORMATS)})") + return Arm(spec) if spec.startswith(REV_ARM): rev = spec[len(REV_ARM):] if not _ARM_REV_RE.match(rev) or ".." in rev: @@ -386,6 +398,8 @@ def prepare_workspace(git_dir: Path, manifest: RepoManifest, task: Task, arm: st except PrepError as exc: raise PrepError(f"task {task.id}: {exc}") from exc installed = _install_skills(ws, task, skills) + if "guidance_skill" in arm_meta: # packaged by guidance:as-skill: usage is measured on it like any skill + installed.append(arm_meta.pop("guidance_skill")) _git(ws, env, "add", "-A") # 6 _git(ws, env, "commit", "-q", "--no-gpg-sign", "--no-verify", "--amend", "--allow-empty", "-m", "snapshot") _apply_review_patch(ws, task, patches) # 7 @@ -455,6 +469,10 @@ def _apply_arm(ws: Path, env: dict[str, str], git_dir: Path, manifest: RepoManif return _apply_patch(ws, env, manifest, arm) tracked = [p for p in _git(ws, env, "ls-files", "-z").split("\0") if p] matched = sorted(p for p in tracked if any(match_glob(g, p) for g in manifest.guidance_globs)) + if arm.base == "guidance:as-skill": + return _apply_as_skill(ws, manifest, matched) + if arm.base.startswith(FORMAT_ARM): + return _apply_format(ws, matched, arm.base[len(FORMAT_ARM):]) if arm.base == "guidance:none" or arm.rev_sha is not None: for rel in matched: (ws / rel).unlink() @@ -465,6 +483,51 @@ def _apply_arm(ws: Path, env: dict[str, str], git_dir: Path, manifest: RepoManif return _apply_bridge(ws, matched, bridge) +def _apply_format(ws: Path, matched: list[str], fmt: str) -> tuple[list[str], dict[str, Any]]: + """Each guidance file rewritten in ``fmt`` in place. A symlink is left alone: its target is rendered + once if it is a guidance file itself, and ``meta.json`` names the links that keep their raw bytes.""" + if not matched: + raise PrepError(f"guidance:format={fmt}: the base has no guidance file to render") + written, links = [], [] + for rel in matched: + path = ws / rel + if path.is_symlink(): + links.append(rel) + continue + try: + path.write_text(to_format(path.read_bytes().decode("utf-8"), fmt), encoding="utf-8") + except (UnicodeDecodeError, FormatError) as exc: + raise PrepError(f"guidance:format={fmt}: {rel}: {exc}") from exc + written.append(rel) + return written, ({"guidance_links_kept": links} if links else {}) + + +def _apply_as_skill(ws: Path, manifest: RepoManifest, matched: list[str]) -> tuple[list[str], dict[str, Any]]: + """The guidance files removed (as ``guidance:none`` removes them) and their text packaged as one skill, + ``.claude/skills/-guidance/SKILL.md``: each file under a ``## File: `` line, its text verbatim.""" + if not matched: + raise PrepError("guidance:as-skill: the base has no guidance file to package") + name = slug(f"{manifest.id}-guidance") + skill_dir = ws / SKILLS_DIR / name + for part in (ws / ".claude", ws / SKILLS_DIR): # a repository link would write to the host + if part.is_symlink(): + raise PrepError(f"guidance:as-skill: {part.relative_to(ws)} is a symlink in the repository") + if skill_dir.exists() or skill_dir.is_symlink(): + raise PrepError(f"guidance:as-skill: the workspace already has {SKILLS_DIR}/{name}") + parts = [] + for rel in matched: + if not (ws / rel).is_symlink(): + text = (ws / rel).read_bytes().decode("utf-8") + parts.append(f"## File: {rel}\n\n" + (text if text.endswith("\n") else text + "\n")) + for rel in matched: + (ws / rel).unlink() + skill_dir.mkdir(parents=True) + frontmatter = f"---\nname: {name}\ndescription: {GUIDANCE_SKILL_DESCRIPTION}\n---\n\n" + (skill_dir / "SKILL.md").write_text(frontmatter + "\n".join(parts), encoding="utf-8") + return matched, {"guidance_skill": {"name": name, "path": f"{SKILLS_DIR}/{name}", + "sha256": tree_sha256(skill_dir)}} + + def _apply_rev(ws: Path, git_dir: Path, manifest: RepoManifest, arm: Arm, removed: list[str]) -> tuple[list[str], dict[str, Any]]: """The guidance files of ``arm.rev_sha`` into ``ws``, whose own were ``removed``.""" diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py new file mode 100644 index 000000000..e56fd9c90 --- /dev/null +++ b/dokimi/tests/test_repo_guidance_format.py @@ -0,0 +1,227 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""The ``guidance:format=`` and ``guidance:as-skill`` arms (contract §3, step 5). No docker, no LLM.""" + +from __future__ import annotations + +import types + +import pytest + +from dokimi.repos import RepoSource, load_manifest, open_source +from dokimi.repos.arms import slug, tree_sha256 +from dokimi.repos.guidance_format import FormatError, restore, to_format +from dokimi.repos.workspace import ( + GUIDANCE_SKILL_DESCRIPTION, + PrepError, + _apply_as_skill, + _apply_format, + export_eval_dir, + load_tasks, + parse_arm, + prepare_workspace, + source_git_dir, +) +from repo_eval_fixture import make_repo, write + +RICH = """Intro line before any heading. + +# Project +Use `make check`. + +## Build & test +Run `make ` with "quotes". + +```bash +# a shell comment, not a heading +make check +``` + +### Deep detail +Text. + +## Style +- keep it short + +# Other +tail""" + + +@pytest.mark.parametrize("text", [ + RICH, + "", + "no heading at all", + "# only\n", + "# a\n### skipped level\n# b\n", + "## start deeper\n# then shallower\n", + "# no trailing newline", + "#no-space is body\n## two spaces is body\n####### seven is body\n", + "# trailing spaces \r\nbody\r\n", + "# Title with
and \"quotes\" & \nbody\n", + "~~~\n# fenced with tildes\n~~~\n# real\n", +]) +def test_xml_round_trips_the_markdown_exactly(text): + assert restore(to_format(text, "xml")) == text + + +def test_xml_wraps_each_heading_in_a_section_closed_before_its_peer(): + assert to_format("# A\nbody\n## B\nmore\n# C\n", "xml") == ( + "\n" + '
\nbody\n' + '
\nmore\n
\n' + "
\n" + '
\n\n' + "
\n" + "
" + ) + + +def test_a_fenced_hash_line_is_body_text_not_a_section(): + rendered = to_format("```\n# not a heading\n```\n", "xml") + assert "\n', "xml") + assert 'title="A "b" & <c>"' in rendered + assert restore(rendered) == '# A "b" & \n' + + +@pytest.mark.parametrize("line", ["
", '
']) +def test_a_body_line_that_looks_like_a_tag_is_refused(line): + with pytest.raises(FormatError): + to_format(f"# A\n{line}\n", "xml") + + +def test_an_unknown_format_is_refused(): + with pytest.raises(FormatError): + to_format("# A\n", "poml") + + +def test_restore_refuses_text_that_is_not_a_rendering(): + with pytest.raises(FormatError): + restore("# plain markdown\n") + + +def test_a_format_arm_parses_as_its_own_guidance_part(): + arm = parse_arm("guidance:format=xml") + assert arm == "guidance:format=xml" and arm.base == "guidance:format=xml" + assert parse_arm("guidance:format=xml+skill:lint").base == "guidance:format=xml" + + +@pytest.mark.parametrize("spec", ["guidance:format=md", "guidance:format=poml", "guidance:format=", "guidance:format=XML"]) +def test_an_unknown_or_duplicate_format_is_a_prep_error(spec): + with pytest.raises(PrepError): + parse_arm(spec) + + +def test_two_guidance_parts_are_refused_for_formats_too(): + with pytest.raises(PrepError): + parse_arm("guidance:format=xml+guidance:as-skill") + + +def test_as_skill_is_a_guidance_part_joined_with_skills(): + assert parse_arm("guidance:as-skill") == "guidance:as-skill" + arm = parse_arm("guidance:as-skill+skill:lint") + assert arm == "guidance:as-skill+skill:lint" and arm.base == "guidance:as-skill" + + +def _toy_files(tmp_path): + root, _ = make_repo(tmp_path) + source = RepoSource.parse(f"{root}@HEAD") + tree = open_source(source) + manifest = load_manifest(tree, ".agent-eval/repo-eval.yaml") + content = export_eval_dir(tree, manifest, tmp_path / "content") + tasks = {t.id: t for t in load_tasks(content, manifest)} + return {"git_dir": source_git_dir(source, tree), "manifest": manifest, "tasks": tasks, "tmp": tmp_path} + + +def _prep(toy, arm): + meta = prepare_workspace(toy["git_dir"], toy["manifest"], toy["tasks"]["fix_add"], arm, toy["tmp"] / "rd") + return meta, toy["tmp"] / "rd" / "workspace" + + +def test_a_format_arm_renders_every_guidance_file_of_the_workspace(tmp_path): + toy = _toy_files(tmp_path) + meta, ws = _prep(toy, "guidance:format=xml") + assert (ws / "AGENTS.md").read_text() == to_format("# guidance\n", "xml") + assert (ws / "sub/AGENTS.md").read_text() == to_format("# sub guidance\n", "xml") + assert restore((ws / "AGENTS.md").read_text()) == "# guidance\n" + assert meta["arm"] == "guidance:format=xml" and not meta.get("skills") + + +def test_an_as_skill_arm_moves_the_guidance_into_one_skill(tmp_path): + toy = _toy_files(tmp_path) + meta, ws = _prep(toy, "guidance:as-skill") + name = slug(f"{toy['manifest'].id}-guidance") + assert not (ws / "AGENTS.md").exists() and not (ws / "sub/AGENTS.md").exists() + skill = (ws / ".claude/skills" / name / "SKILL.md").read_text() + assert skill.startswith(f"---\nname: {name}\ndescription: {GUIDANCE_SKILL_DESCRIPTION}\n---\n") + assert "## File: AGENTS.md\n\n# guidance\n" in skill and "## File: sub/AGENTS.md\n\n# sub guidance\n" in skill + assert meta["skills"] == [{"name": name, "path": f".claude/skills/{name}", + "sha256": tree_sha256(ws / ".claude/skills" / name)}] + assert "guidance_skill" not in meta + assert sorted(meta["arm_files"]) == ["AGENTS.md", "sub/AGENTS.md"] + + +def test_format_keeps_a_symlink_as_a_link_and_renders_its_target_once(tmp_path): + ws = tmp_path / "ws" + write(ws, "AGENTS.md", "# A\nbody\n") + (ws / "CLAUDE.md").symlink_to("AGENTS.md") + written, meta = _apply_format(ws, ["AGENTS.md", "CLAUDE.md"], "xml") + assert written == ["AGENTS.md"] and meta == {"guidance_links_kept": ["CLAUDE.md"]} + assert (ws / "CLAUDE.md").is_symlink() + assert (ws / "CLAUDE.md").read_text() == to_format("# A\nbody\n", "xml") + + +def test_format_refuses_a_file_that_is_not_utf8(tmp_path): + ws = tmp_path / "ws" + (ws / "AGENTS.md").parent.mkdir(parents=True) + (ws / "AGENTS.md").write_bytes(b"\xff\xfe# A\n") + with pytest.raises(PrepError, match="AGENTS.md"): + _apply_format(ws, ["AGENTS.md"], "xml") + + +def test_format_refuses_a_file_that_would_not_restore(tmp_path): + ws = tmp_path / "ws" + write(ws, "AGENTS.md", "# A\n
\n") + with pytest.raises(PrepError, match="AGENTS.md"): + _apply_format(ws, ["AGENTS.md"], "xml") + + +def test_format_and_as_skill_need_a_guidance_file(tmp_path): + ws = tmp_path / "ws" + ws.mkdir() + with pytest.raises(PrepError): + _apply_format(ws, [], "xml") + with pytest.raises(PrepError): + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), []) + + +def test_as_skill_refuses_a_skill_the_workspace_already_has(tmp_path): + ws = tmp_path / "ws" + write(ws, "AGENTS.md", "# A\n") + write(ws, ".claude/skills/toy-guidance/SKILL.md", "---\nname: toy-guidance\ndescription: x\n---\n") + with pytest.raises(PrepError, match="already has"): + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md"]) + + +def test_as_skill_refuses_a_claude_directory_that_is_a_link(tmp_path): + ws, host = tmp_path / "ws", tmp_path / "host" + write(ws, "AGENTS.md", "# A\n") + host.mkdir() + (ws / ".claude").symlink_to(host) + with pytest.raises(PrepError, match="symlink"): + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md"]) + assert not any(host.iterdir()) + + +def test_the_text_of_each_guidance_file_is_in_the_skill_verbatim(tmp_path): + ws = tmp_path / "ws" + write(ws, "AGENTS.md", RICH) + write(ws, "src/AGENTS.md", "# src\nno final newline") + _, meta = _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md", "src/AGENTS.md"]) + skill = (ws / ".claude/skills/toy-guidance/SKILL.md").read_text() + assert RICH in skill and "# src\nno final newline\n" in skill + assert meta["guidance_skill"]["name"] == "toy-guidance" From e734e16e704956e88fbaaff57d86ffe5205218aa Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 18:26:03 -0700 Subject: [PATCH 02/22] fix(repo): valid skill frontmatter and clean failures for guidance:as-skill Self-review of 78457e26 found three faults in the new arms: - The packaged SKILL.md had a description with ": " in a plain YAML scalar, so the frontmatter did not parse and the skill could not load. - A guidance file that is not UTF-8 raised UnicodeDecodeError instead of a PrepError for the cell (guidance:format and guidance:as-skill share the reader now). - A symlinked guidance file was dropped without a trace; a link to another guidance file is now packaged once under its target, and a link to any other file fails the cell. Signed-off-by: Nikolay Petrov --- dokimi/docs/benchmarking-and-comparison.md | 4 ++- dokimi/dokimi/repos/workspace.py | 30 ++++++++++++------ dokimi/tests/test_repo_guidance_format.py | 37 ++++++++++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/dokimi/docs/benchmarking-and-comparison.md b/dokimi/docs/benchmarking-and-comparison.md index fc56cee9f..5b12ffe43 100644 --- a/dokimi/docs/benchmarking-and-comparison.md +++ b/dokimi/docs/benchmarking-and-comparison.md @@ -322,7 +322,9 @@ the cells the way the skill arms are compared above. needs a decision about which element a heading maps to. - `guidance:as-skill`: the guidance files are removed (as `guidance:none` removes them) and their text goes into `.claude/skills/-guidance/SKILL.md`, each - file under a `## File: ` line with its text verbatim. The frontmatter + file under a `## File: ` line with its text verbatim; a symlink to another + guidance file (`CLAUDE.md` to `AGENTS.md`) is packaged once under that file, and a + symlink to any other file fails the cell. The frontmatter description is one fixed sentence for every repository, so the skill is selected on its description alone. Nested guidance files lose the directory they applied to; the skill records its sha256 and appears in `skills` like any diff --git a/dokimi/dokimi/repos/workspace.py b/dokimi/dokimi/repos/workspace.py index f7497de89..157aac791 100644 --- a/dokimi/dokimi/repos/workspace.py +++ b/dokimi/dokimi/repos/workspace.py @@ -60,8 +60,8 @@ ARMS = ("guidance:none", "guidance:raw", "guidance:bridge", "guidance:as-skill") # the guidance parts #: The description of the skill ``guidance:as-skill`` packages the guidance into: the same for every repository. -GUIDANCE_SKILL_DESCRIPTION = ("Repository guidance for working in this codebase: its conventions, build and test " - "commands and rules. Load it before changing its code.") +GUIDANCE_SKILL_DESCRIPTION = ("Repository guidance for working in this codebase, its conventions, build and test " + "commands and rules. Load it before changing its code.") # no ": " (YAML plain scalar) REV_ARM, PATCH_ARM, FORMAT_ARM = "guidance:rev:", "patch:", "guidance:format=" #: Every form ``repo run --arm`` takes ARM_FORMS = (*ARMS, f"{REV_ARM}", f"{PATCH_ARM}", f"{FORMAT_ARM}<{'|'.join(FORMATS)}>", @@ -490,18 +490,26 @@ def _apply_format(ws: Path, matched: list[str], fmt: str) -> tuple[list[str], di raise PrepError(f"guidance:format={fmt}: the base has no guidance file to render") written, links = [], [] for rel in matched: - path = ws / rel - if path.is_symlink(): + if (ws / rel).is_symlink(): links.append(rel) continue try: - path.write_text(to_format(path.read_bytes().decode("utf-8"), fmt), encoding="utf-8") - except (UnicodeDecodeError, FormatError) as exc: + rendered = to_format(_guidance_text(ws, rel, f"guidance:format={fmt}"), fmt) + except FormatError as exc: raise PrepError(f"guidance:format={fmt}: {rel}: {exc}") from exc + (ws / rel).write_text(rendered, encoding="utf-8") written.append(rel) return written, ({"guidance_links_kept": links} if links else {}) +def _guidance_text(ws: Path, rel: str, arm: str) -> str: + """The text of a guidance file of the workspace; one that is not UTF-8 is a prep error of the cell.""" + try: + return (ws / rel).read_bytes().decode("utf-8") + except UnicodeDecodeError as exc: + raise PrepError(f"{arm}: {rel} is not UTF-8: {exc}") from exc + + def _apply_as_skill(ws: Path, manifest: RepoManifest, matched: list[str]) -> tuple[list[str], dict[str, Any]]: """The guidance files removed (as ``guidance:none`` removes them) and their text packaged as one skill, ``.claude/skills/-guidance/SKILL.md``: each file under a ``## File: `` line, its text verbatim.""" @@ -516,9 +524,13 @@ def _apply_as_skill(ws: Path, manifest: RepoManifest, matched: list[str]) -> tup raise PrepError(f"guidance:as-skill: the workspace already has {SKILLS_DIR}/{name}") parts = [] for rel in matched: - if not (ws / rel).is_symlink(): - text = (ws / rel).read_bytes().decode("utf-8") - parts.append(f"## File: {rel}\n\n" + (text if text.endswith("\n") else text + "\n")) + if (ws / rel).is_symlink(): # packaged once under its target's name; a link to anything else would be lost + target = posixpath.normpath(posixpath.join(posixpath.dirname(rel), os.readlink(ws / rel))) + if target not in matched: + raise PrepError(f"guidance:as-skill: {rel} links to {target}, which is not a guidance file") + continue + text = _guidance_text(ws, rel, "guidance:as-skill") + parts.append(f"## File: {rel}\n\n" + (text if text.endswith("\n") else text + "\n")) for rel in matched: (ws / rel).unlink() skill_dir.mkdir(parents=True) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index e56fd9c90..5b64ed4ab 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -8,6 +8,7 @@ import types import pytest +import yaml from dokimi.repos import RepoSource, load_manifest, open_source from dokimi.repos.arms import slug, tree_sha256 @@ -225,3 +226,39 @@ def test_the_text_of_each_guidance_file_is_in_the_skill_verbatim(tmp_path): skill = (ws / ".claude/skills/toy-guidance/SKILL.md").read_text() assert RICH in skill and "# src\nno final newline\n" in skill assert meta["guidance_skill"]["name"] == "toy-guidance" + + +def test_the_skill_frontmatter_is_valid_yaml_with_name_and_description(tmp_path): + ws = tmp_path / "ws" + write(ws, "AGENTS.md", "# A\n") + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md"]) + skill = (ws / ".claude/skills/toy-guidance/SKILL.md").read_text() + frontmatter = yaml.safe_load(skill.split("---\n")[1]) + assert frontmatter == {"name": "toy-guidance", "description": GUIDANCE_SKILL_DESCRIPTION} + + +def test_as_skill_refuses_a_file_that_is_not_utf8_as_a_prep_error(tmp_path): + ws = tmp_path / "ws" + (ws / "AGENTS.md").parent.mkdir(parents=True) + (ws / "AGENTS.md").write_bytes(b"\xff# A\n") + with pytest.raises(PrepError, match="AGENTS.md"): + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md"]) + + +def test_as_skill_packages_a_linked_guidance_file_once(tmp_path): + ws = tmp_path / "ws" + write(ws, "AGENTS.md", "# A\nbody\n") + (ws / "CLAUDE.md").symlink_to("AGENTS.md") + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md", "CLAUDE.md"]) + skill = (ws / ".claude/skills/toy-guidance/SKILL.md").read_text() + assert skill.count("# A\nbody\n") == 1 and "## File: CLAUDE.md" not in skill + assert not (ws / "CLAUDE.md").is_symlink() and not (ws / "CLAUDE.md").exists() + + +def test_as_skill_refuses_a_link_to_a_file_that_is_not_guidance(tmp_path): + ws = tmp_path / "ws" + write(ws, "docs/guide.md", "# guide\n") + write(ws, "AGENTS.md", "# A\n") + (ws / "CLAUDE.md").symlink_to("docs/guide.md") + with pytest.raises(PrepError, match="not a guidance file"): + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md", "CLAUDE.md"]) From cf6ced2e0767031dd2bc4f1faf270683c963aa6f Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 23:12:34 -0700 Subject: [PATCH 03/22] fix(repo): guidance:format fences, symlink checks and LF-safe writes - a fence closes only on a run at least as long as its opener, of the same character, and never inside a longer run of backticks or tildes - guidance:as-skill and guidance:format follow symlink chains: a chain that cycles or ends outside the matched guidance files is a prep error - guidance:as-skill removes the base skill of the same name the arm packages, so check_arms does not refuse it (drops rule also covers as-skill) - guidance files and the packaged SKILL.md are written as bytes, so no platform newline translation runs after the round-trip check Thread: PRRT_kwDOVBg7g86yWVOg Thread: PRRT_kwDOVBg7g86yWVZ2 Thread: PRRT_kwDOVBg7g86yWVOl Thread: PRRT_kwDOVBg7g86yWVZ4 Thread: PRRT_kwDOVBg7g86yWVOr Thread: PRRT_kwDOVBg7g86yWVZ8 Thread: PRRT_kwDOVBg7g86yWVZ6 Thread: PRRT_kwDOVBg7g86yWVZ_ Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/arms.py | 2 +- dokimi/dokimi/repos/guidance_format.py | 20 ++++++++--- dokimi/dokimi/repos/workspace.py | 22 +++++++++--- dokimi/tests/test_repo_guidance_format.py | 41 +++++++++++++++++++++++ dokimi/tests/test_repo_skill_arms.py | 14 ++++++++ 5 files changed, 88 insertions(+), 11 deletions(-) diff --git a/dokimi/dokimi/repos/arms.py b/dokimi/dokimi/repos/arms.py index ee96d526c..5275acd3c 100644 --- a/dokimi/dokimi/repos/arms.py +++ b/dokimi/dokimi/repos/arms.py @@ -197,7 +197,7 @@ def check_arms(arms: list[Any], content_dir: Path, tasks: list[Any], git_dir: Pa if cells.setdefault(cell, arm.spec) != arm.spec: raise ArmError(f"arms {cells[cell]!r} and {arm.spec!r} would share one result directory ({cell})") skills = resolve_skills(arm, content_dir) - drops = arm.patch is None and (arm.base == "guidance:none" or arm.rev is not None) + drops = arm.patch is None and (arm.base in ("guidance:none", "guidance:as-skill") or arm.rev is not None) rev = (arm.rev_sha or arm.rev) if arm.patch is None else None if skills and git_dir is not None: for task in tasks: diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 0945658ee..22be24213 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -20,7 +20,7 @@ _ROOT, _ROOT_CLOSE, _CLOSE = "", "", "
" _HEADING = re.compile(r"^(#{1,6}) (\S[^\r]*)$") # canonical only: one space, no CR; anything else is body text -_FENCE = re.compile(r"^\s{0,3}(```|~~~)") +_FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})(.*)$") # a run of ``` or ~~~, and what follows it on the line _OPEN = re.compile(r'^
$') _ESCAPES = {"&": "&", "<": "<", ">": ">", '"': """} _UNESCAPES = {v: k for k, v in _ESCAPES.items()} @@ -54,13 +54,23 @@ def restore(rendered: str) -> str: return "\n".join(out) +def _fence_after(fence: str | None, line: str) -> str | None: + """The fence a line leaves open (its opening run), or None. A run of the same character, at least as long as the + opening one and followed by nothing but whitespace, closes it; any other fence line is body text.""" + match = _FENCE.match(line) + if fence is None: + return match.group(1) if match else None + if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence) and not match.group(2).strip(): + return None + return fence + + def _render_xml(text: str) -> str: - out, fenced = [_ROOT], False + out, fence = [_ROOT], None open_levels: list[int] = [] for line in text.split("\n"): - heading = None if fenced else _HEADING.match(line) - if _FENCE.match(line): - fenced = not fenced + heading = None if fence else _HEADING.match(line) + fence = _fence_after(fence, line) if heading is None: out.append(line) continue diff --git a/dokimi/dokimi/repos/workspace.py b/dokimi/dokimi/repos/workspace.py index 157aac791..02b79555f 100644 --- a/dokimi/dokimi/repos/workspace.py +++ b/dokimi/dokimi/repos/workspace.py @@ -491,17 +491,31 @@ def _apply_format(ws: Path, matched: list[str], fmt: str) -> tuple[list[str], di written, links = [], [] for rel in matched: if (ws / rel).is_symlink(): + _link_file(ws, rel, matched) # the link must reach a file that is rendered, not raw guidance links.append(rel) continue try: rendered = to_format(_guidance_text(ws, rel, f"guidance:format={fmt}"), fmt) except FormatError as exc: raise PrepError(f"guidance:format={fmt}: {rel}: {exc}") from exc - (ws / rel).write_text(rendered, encoding="utf-8") + (ws / rel).write_bytes(rendered.encode("utf-8")) # no newline translation: the restore check holds on disk written.append(rel) return written, ({"guidance_links_kept": links} if links else {}) +def _link_file(ws: Path, rel: str, matched: list[str]) -> None: + """The regular guidance file a link of the workspace reaches, following the links in turn. PrepError when the + chain cycles or ends outside the matched guidance files: that file would be dropped or left unrendered.""" + chain = [rel] + while (ws / chain[-1]).is_symlink(): + target = posixpath.normpath(posixpath.join(posixpath.dirname(chain[-1]), os.readlink(ws / chain[-1]))) + if target in chain: + raise PrepError(f"guidance link cycle: {' -> '.join([*chain, target])}") + chain.append(target) + if chain[-1] not in matched: + raise PrepError(f"guidance link {rel} reaches {chain[-1]}, which is not a guidance file") + + def _guidance_text(ws: Path, rel: str, arm: str) -> str: """The text of a guidance file of the workspace; one that is not UTF-8 is a prep error of the cell.""" try: @@ -525,9 +539,7 @@ def _apply_as_skill(ws: Path, manifest: RepoManifest, matched: list[str]) -> tup parts = [] for rel in matched: if (ws / rel).is_symlink(): # packaged once under its target's name; a link to anything else would be lost - target = posixpath.normpath(posixpath.join(posixpath.dirname(rel), os.readlink(ws / rel))) - if target not in matched: - raise PrepError(f"guidance:as-skill: {rel} links to {target}, which is not a guidance file") + _link_file(ws, rel, matched) continue text = _guidance_text(ws, rel, "guidance:as-skill") parts.append(f"## File: {rel}\n\n" + (text if text.endswith("\n") else text + "\n")) @@ -535,7 +547,7 @@ def _apply_as_skill(ws: Path, manifest: RepoManifest, matched: list[str]) -> tup (ws / rel).unlink() skill_dir.mkdir(parents=True) frontmatter = f"---\nname: {name}\ndescription: {GUIDANCE_SKILL_DESCRIPTION}\n---\n\n" - (skill_dir / "SKILL.md").write_text(frontmatter + "\n".join(parts), encoding="utf-8") + (skill_dir / "SKILL.md").write_bytes((frontmatter + "\n".join(parts)).encode("utf-8")) # verbatim: no newline translation return matched, {"guidance_skill": {"name": name, "path": f"{SKILLS_DIR}/{name}", "sha256": tree_sha256(skill_dir)}} diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 5b64ed4ab..0cfb99e78 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -262,3 +262,44 @@ def test_as_skill_refuses_a_link_to_a_file_that_is_not_guidance(tmp_path): (ws / "CLAUDE.md").symlink_to("docs/guide.md") with pytest.raises(PrepError, match="not a guidance file"): _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md", "CLAUDE.md"]) + + +# -- review of #333: the fence delimiter, the links, the bytes on disk ---------------------------------------- + + +def test_a_fence_closes_only_on_its_own_delimiter_and_length(): + assert "' in rendered + + +def test_a_format_link_to_a_file_the_globs_do_not_match_is_a_prep_error(tmp_path): + ws = tmp_path / "ws" + write(ws, "docs/guide.md", "# guide\n") + (ws / "AGENTS.md").symlink_to("docs/guide.md") + with pytest.raises(PrepError, match="not a guidance file"): + _apply_format(ws, ["AGENTS.md"], "xml") + + +def test_a_link_cycle_is_refused_and_nothing_is_deleted(tmp_path): + ws = tmp_path / "ws" + ws.mkdir() + (ws / "AGENTS.md").symlink_to("CLAUDE.md") + (ws / "CLAUDE.md").symlink_to("AGENTS.md") + with pytest.raises(PrepError, match="cycle"): + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md", "CLAUDE.md"]) + assert (ws / "AGENTS.md").is_symlink() and (ws / "CLAUDE.md").is_symlink() + with pytest.raises(PrepError, match="cycle"): + _apply_format(ws, ["AGENTS.md", "CLAUDE.md"], "xml") + + +def test_format_and_as_skill_write_the_bytes_they_read_with_no_newline_translation(tmp_path): + ws = tmp_path / "ws" + ws.mkdir() + (ws / "AGENTS.md").write_bytes(b"# A\r\nbody\r\n") + _apply_format(ws, ["AGENTS.md"], "xml") + assert (ws / "AGENTS.md").read_bytes() == to_format("# A\r\nbody\r\n", "xml").encode("utf-8") + (ws / "AGENTS.md").write_bytes(b"# A\r\nbody\r\n") + _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md"]) + assert b"body\r\n" in (ws / ".claude/skills/toy-guidance/SKILL.md").read_bytes() diff --git a/dokimi/tests/test_repo_skill_arms.py b/dokimi/tests/test_repo_skill_arms.py index c099ec173..1994abd9f 100644 --- a/dokimi/tests/test_repo_skill_arms.py +++ b/dokimi/tests/test_repo_skill_arms.py @@ -774,3 +774,17 @@ def test_the_base_check_refuses_a_tracked_symlink_at_claude_or_its_skills(tmp_pa def test_a_frontmatter_name_that_yaml_reads_as_an_impossible_date_is_the_directory_name_not_a_crash(tmp_path): write(tmp_path, "skills/lint/SKILL.md", "---\nname: 2024-13-45\n---\nBody.\n") assert [s.name for s in resolve_skills(parse_arm("skill:skills/lint"), tmp_path)] == ["lint"] + + +def test_the_as_skill_arm_removes_the_base_skill_it_packages_so_it_is_not_refused(tmp_path): + _skill(tmp_path / "toy", ".claude/skills/lint", "lint") # make_repo commits this as the base, which guidance:raw refuses + root, base = make_repo(tmp_path) + _skill(root, ".agent-eval/skills/lint", "lint") + _skill(root, ".claude/skills/lint", "lint") + git(root, "add", "-A") + git(root, "commit", "-qm", "skills") + tasks = [Task("t1", {"base": base}, root)] + content, globs = root / ".agent-eval", ["**/AGENTS.md", ".claude/skills/**"] + check_arms([parse_arm("guidance:as-skill+skill:skills/lint")], content, tasks, root / ".git", globs) + with pytest.raises(ArmError, match="already has .claude/skills/lint"): # the same base under guidance:raw + check_arms([parse_arm("guidance:raw+skill:skills/lint")], content, tasks, root / ".git", globs) From 1db497bb9276b3fc1f98b3c67f8df042f399ca14 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 14:10:59 +0000 Subject: [PATCH 04/22] List the new guidance arms in repo run help and the docs status Signed-off-by: Anton Volkov --- dokimi/docs/preparing-a-repository.md | 3 ++- dokimi/dokimi/commands/repo_run.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/dokimi/docs/preparing-a-repository.md b/dokimi/docs/preparing-a-repository.md index e52910f23..3aaa2e766 100644 --- a/dokimi/docs/preparing-a-repository.md +++ b/dokimi/docs/preparing-a-repository.md @@ -6,7 +6,8 @@ SPDX-License-Identifier: Apache-2.0 # Preparing a repository for coding agents > **Status: recommendations.** §1–§7 apply today. The measurement loop in §8 runs -> today for guidance arms (`guidance:none`, `guidance:raw`, `guidance:bridge`). +> today for guidance arms (`guidance:none`, `guidance:raw`, `guidance:bridge`, +> `guidance:as-skill`, `guidance:format=xml`). > `guidance:bridge` measures the `CLAUDE.md` import (§7). The `skill:` and > `skill-catalog:` arms, which install skills, run today too > ([skill arms](benchmarking-and-comparison.md#skill-arms-in-repository-mode)). Arms diff --git a/dokimi/dokimi/commands/repo_run.py b/dokimi/dokimi/commands/repo_run.py index 8c81f0859..4bd110ada 100644 --- a/dokimi/dokimi/commands/repo_run.py +++ b/dokimi/dokimi/commands/repo_run.py @@ -460,9 +460,9 @@ def register(repo_sub, positive_int, source_help: str, cache_help: str) -> None: "result.json per cell, already finished cells skipped") _common(run_p, source_help, cache_help, positive_int) run_p.add_argument("--arm", action="append", default=None, required=True, - help="guidance:none | guidance:raw | guidance:bridge | guidance:rev:REV (the base with " - "the guidance files of revision REV of the source; the arm is named by REV's full SHA, " - "resolved once per batch) | patch:FILE (a unified diff on this host, applied with git " + help="guidance:none | guidance:raw | guidance:bridge | guidance:as-skill | " + "guidance:format=xml | guidance:rev:REV (the base with the guidance files of revision REV " + "of the source; the arm is named by REV's full SHA, resolved once per batch) | patch:FILE (a unified diff on this host, applied with git " "apply on top of guidance:raw; named patch:@, path and " "sha256 in meta.json) | skill: | skill-catalog: (skills installed in the " "workspace; a relative dir is in eval_dir; named by the directory, not the path). A " From 6bd48b444cd2794474331a1a7e18bec42e0950ee Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 09:26:24 -0800 Subject: [PATCH 05/22] fix(repo): guidance fence openers, links through linked directories, guard tests A fence opener follows CommonMark: up to three spaces (not a tab), and a backtick fence's info string holds no backtick. A link of a guidance file is resolved as the filesystem resolves it, so a `..` after a linked directory reaches the file it names, and a dangling link or a cycle is a PrepError. The guard against writes through a linked .claude/skills and a dangling skill directory are pinned by tests. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/guidance_format.py | 14 +++- dokimi/dokimi/repos/workspace.py | 23 +++--- dokimi/tests/test_repo_guidance_format.py | 88 +++++++++++++++++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 22be24213..44c4f6be9 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -20,7 +20,7 @@ _ROOT, _ROOT_CLOSE, _CLOSE = "", "", "
" _HEADING = re.compile(r"^(#{1,6}) (\S[^\r]*)$") # canonical only: one space, no CR; anything else is body text -_FENCE = re.compile(r"^\s{0,3}(`{3,}|~{3,})(.*)$") # a run of ``` or ~~~, and what follows it on the line +_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$") # up to three spaces, a run of ``` or ~~~, and what follows it _OPEN = re.compile(r'^
$') _ESCAPES = {"&": "&", "<": "<", ">": ">", '"': """} _UNESCAPES = {v: k for k, v in _ESCAPES.items()} @@ -54,12 +54,20 @@ def restore(rendered: str) -> str: return "\n".join(out) +def _opening_run(line: str) -> str | None: + """The run a line opens a fence with, or None. As in CommonMark, a backtick fence's info string has no backtick.""" + match = _FENCE.match(line) + if match is None or (match.group(1)[0] == "`" and "`" in match.group(2)): + return None + return match.group(1) + + def _fence_after(fence: str | None, line: str) -> str | None: """The fence a line leaves open (its opening run), or None. A run of the same character, at least as long as the opening one and followed by nothing but whitespace, closes it; any other fence line is body text.""" - match = _FENCE.match(line) if fence is None: - return match.group(1) if match else None + return _opening_run(line) + match = _FENCE.match(line) if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence) and not match.group(2).strip(): return None return fence diff --git a/dokimi/dokimi/repos/workspace.py b/dokimi/dokimi/repos/workspace.py index 02b79555f..920d8361e 100644 --- a/dokimi/dokimi/repos/workspace.py +++ b/dokimi/dokimi/repos/workspace.py @@ -504,16 +504,19 @@ def _apply_format(ws: Path, matched: list[str], fmt: str) -> tuple[list[str], di def _link_file(ws: Path, rel: str, matched: list[str]) -> None: - """The regular guidance file a link of the workspace reaches, following the links in turn. PrepError when the - chain cycles or ends outside the matched guidance files: that file would be dropped or left unrendered.""" - chain = [rel] - while (ws / chain[-1]).is_symlink(): - target = posixpath.normpath(posixpath.join(posixpath.dirname(chain[-1]), os.readlink(ws / chain[-1]))) - if target in chain: - raise PrepError(f"guidance link cycle: {' -> '.join([*chain, target])}") - chain.append(target) - if chain[-1] not in matched: - raise PrepError(f"guidance link {rel} reaches {chain[-1]}, which is not a guidance file") + """The regular file a guidance link of the workspace reaches, resolved as the filesystem resolves it (a linked + directory on the way counts). PrepError when the link dangles or cycles, or reaches a file outside the workspace + or outside the matched guidance files: that file would be dropped or left unrendered.""" + try: + target = (ws / rel).resolve(strict=True) + except (OSError, RuntimeError) as exc: # RuntimeError: a symlink loop, before Python 3.13 + raise PrepError(f"guidance link {rel} does not reach a file (a dangling link or a cycle): {exc}") from exc + root = ws.resolve() + if not target.is_relative_to(root) or not target.is_file(): + raise PrepError(f"guidance link {rel} reaches {target}, which is outside the workspace or not a regular file") + reached = target.relative_to(root).as_posix() + if reached not in matched: + raise PrepError(f"guidance link {rel} reaches {reached}, which is not a guidance file") def _guidance_text(ws: Path, rel: str, arm: str) -> str: diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 0cfb99e78..09af2585e 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -303,3 +303,91 @@ def test_format_and_as_skill_write_the_bytes_they_read_with_no_newline_translati (ws / "AGENTS.md").write_bytes(b"# A\r\nbody\r\n") _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md"]) assert b"body\r\n" in (ws / ".claude/skills/toy-guidance/SKILL.md").read_bytes() + + +def test_a_fence_opener_is_one_only_where_markdown_accepts_it(): + # a backtick in the info string: not a fence, so the heading after the line is a section + assert '
' in to_format("```foo`bar\n# Heading\n", "xml") + # a tab is four columns of indentation: indented code, not a fence + assert '
' in to_format("\t```\n# H\n", "xml") + + +def test_a_fence_closes_only_on_its_own_character_and_on_no_info_string(): + # a tilde run opens a fence: the comment in it is body text + rendered = to_format("~~~\n# comment\n~~~\n# real\n", "xml") + assert 'title="comment"' not in rendered and '
' in rendered + # a ``` line followed by an info string does not close a ``` fence + rendered = to_format("```\n```bash\n# comment\n```\n# real\n", "xml") + assert rendered.count(" Date: Sun, 1 Nov 2026 09:54:43 -0800 Subject: [PATCH 06/22] test(repo): as-skill packages beside a skill the base ships, no collision Pins the case the review asked for: a base that ships .claude/skills/lint keeps that skill, and guidance:as-skill installs its own skill next to it. Signed-off-by: Nikolay Petrov --- dokimi/tests/test_repo_guidance_format.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 09af2585e..910362c54 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -391,3 +391,13 @@ def test_as_skill_refuses_a_dangling_link_at_the_skill_directory(tmp_path): with pytest.raises(PrepError, match="already has"): _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md"]) assert list(host.iterdir()) == [] + + +def test_as_skill_packages_beside_a_skill_the_base_ships_without_a_collision(tmp_path): + ws = tmp_path / "ws" + write(ws, "AGENTS.md", "# a\n") + write(ws, ".claude/skills/lint/SKILL.md", "---\nname: lint\n---\n") + _, meta = _apply_as_skill(ws, types.SimpleNamespace(id="toy"), ["AGENTS.md"]) + assert (ws / ".claude/skills/lint/SKILL.md").read_text(encoding="utf-8") == "---\nname: lint\n---\n" + assert (ws / ".claude/skills/toy-guidance/SKILL.md").is_file() + assert meta["guidance_skill"]["path"] == ".claude/skills/toy-guidance" From d94e063fad57c252af6d8f29f4cfab99f3b93852 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 11:10:03 -0800 Subject: [PATCH 07/22] fix(format): a fence opened by a list item ends with the item A fence opened in a list item (- ```) was not seen, while its closing delimiter, indented to the item's column, opened a fence that never closed, so the headings after the block stayed Markdown. A fence now records the column of its item: its closer may be indented up to three columns past that column, and a line indented less than it ends the item and the fence. Red before the fix: the bullet and the ordered-item cases fail; the list-item tests pass now. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/guidance_format.py | 45 +++++++++++++++-------- dokimi/tests/test_repo_guidance_format.py | 13 +++++++ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 44c4f6be9..a1d9195c0 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -20,7 +20,8 @@ _ROOT, _ROOT_CLOSE, _CLOSE = "", "", "
" _HEADING = re.compile(r"^(#{1,6}) (\S[^\r]*)$") # canonical only: one space, no CR; anything else is body text -_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$") # up to three spaces, a run of ``` or ~~~, and what follows it +_FENCE = re.compile(r"^( *)(`{3,}|~{3,})(.*)$") # the indentation, a run of ``` or ~~~, and what follows it +_ITEM = re.compile(r"^( *(?:[-*+]|\d{1,9}[.)]) +)") # a list item's marker and the spaces after it _OPEN = re.compile(r'^
$') _ESCAPES = {"&": "&", "<": "<", ">": ">", '"': """} _UNESCAPES = {v: k for k, v in _ESCAPES.items()} @@ -54,31 +55,45 @@ def restore(rendered: str) -> str: return "\n".join(out) -def _opening_run(line: str) -> str | None: - """The run a line opens a fence with, or None. As in CommonMark, a backtick fence's info string has no backtick.""" - match = _FENCE.match(line) +def _opened(line: str) -> tuple[str, int] | None: + """The fence a line opens, as (its run, the column its content starts at), or None. A fence may open a list item + (``- ```) and then its content is indented to the item's column. As in CommonMark, a backtick fence's info string + has no backtick.""" + item = _ITEM.match(line) + lead = item.group(1) if item else "" + match = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line[len(lead):]) if match is None or (match.group(1)[0] == "`" and "`" in match.group(2)): return None - return match.group(1) + return match.group(1), len(lead) -def _fence_after(fence: str | None, line: str) -> str | None: - """The fence a line leaves open (its opening run), or None. A run of the same character, at least as long as the - opening one and followed by nothing but whitespace, closes it; any other fence line is body text.""" - if fence is None: - return _opening_run(line) +def _closes(fence: tuple[str, int], line: str) -> bool: + """Whether a line closes the fence: a run of its character, at least as long as the opening one, followed by + nothing but whitespace, indented at most three columns past the item's content column.""" + run, column = fence match = _FENCE.match(line) - if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence) and not match.group(2).strip(): - return None - return fence + return bool(match and match.group(2)[0] == run[0] and len(match.group(2)) >= len(run) + and not match.group(3).strip() and len(match.group(1)) <= column + 3) + + +def _advance(fence: tuple[str, int] | None, line: str) -> tuple[str, int] | None: + """The fence open after a line. A line indented less than the column of the item that opened the fence ends the + item, and the fence with it; the line is then read as any other line.""" + if fence is not None: + expanded = line.expandtabs(4) # a tab is indentation up to the next multiple of four + if line.strip() and len(expanded) - len(expanded.lstrip(" ")) < fence[1]: + fence = None + else: + return None if _closes(fence, line) else fence + return _opened(line) def _render_xml(text: str) -> str: out, fence = [_ROOT], None open_levels: list[int] = [] for line in text.split("\n"): - heading = None if fence else _HEADING.match(line) - fence = _fence_after(fence, line) + fence = _advance(fence, line) + heading = _HEADING.match(line) if fence is None else None # a line of a fence is body text if heading is None: out.append(line) continue diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 910362c54..7e0f2e574 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -5,6 +5,7 @@ from __future__ import annotations +import re import types import pytest @@ -49,6 +50,18 @@ tail""" +@pytest.mark.parametrize("text, sections", [ + ("- ```\n echo hi\n ```\n# Real\n", ["Real"]), # the closer is indented to the item's content column + ("- ```\n echo hi\n# Real\n", ["Real"]), # a line less indented than the item ends the item and its fence + ("- a\n - ```\n # not\n ```\n# Real\n", ["Real"]), # a nested item + ("1. ```\n # not\n ```\n# Real\n", ["Real"]), # an ordered item + ("- ```\n# Real\n```\n# Real2\n", ["Real"]), # the item ends; the ``` at column 0 opens a fence over Real2 + ("- use ``` for code\n# Real\n", ["Real"]), # a list item that is prose, not a fence +]) +def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): + assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections + + @pytest.mark.parametrize("text", [ RICH, "", From d1d0672781246dba0b945085ceb8eb32a0e496a3 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 11:11:22 -0800 Subject: [PATCH 08/22] fix(format): list markers and tabs in fence openers and closers A list marker takes one to four spaces before its content, so five spaces make indented code, not a fence; several markers on one line (- - ```) open an item each. A tab counts to the next multiple of four in a closer as it does in an item's indentation. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/guidance_format.py | 18 ++++++++++-------- dokimi/tests/test_repo_guidance_format.py | 3 +++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index a1d9195c0..400fb1383 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -21,7 +21,8 @@ _ROOT, _ROOT_CLOSE, _CLOSE = "", "", "
" _HEADING = re.compile(r"^(#{1,6}) (\S[^\r]*)$") # canonical only: one space, no CR; anything else is body text _FENCE = re.compile(r"^( *)(`{3,}|~{3,})(.*)$") # the indentation, a run of ``` or ~~~, and what follows it -_ITEM = re.compile(r"^( *(?:[-*+]|\d{1,9}[.)]) +)") # a list item's marker and the spaces after it +_ITEM = re.compile(r" *(?:[-*+]|\d{1,9}[.)]) {1,4}(?=\S)") # a list marker and the one to four spaces after it +_OPENER = re.compile(r" {0,3}(`{3,}|~{3,})(.*)$") # up to three spaces, then a run that opens a fence _OPEN = re.compile(r'^
$') _ESCAPES = {"&": "&", "<": "<", ">": ">", '"': """} _UNESCAPES = {v: k for k, v in _ESCAPES.items()} @@ -57,21 +58,22 @@ def restore(rendered: str) -> str: def _opened(line: str) -> tuple[str, int] | None: """The fence a line opens, as (its run, the column its content starts at), or None. A fence may open a list item - (``- ```) and then its content is indented to the item's column. As in CommonMark, a backtick fence's info string - has no backtick.""" - item = _ITEM.match(line) - lead = item.group(1) if item else "" - match = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line[len(lead):]) + (``- ```, ``- - ```) and then its content is indented to the item's column. As in CommonMark, a backtick fence's + info string has no backtick.""" + column = 0 + while (item := _ITEM.match(line, column)) is not None: + column = item.end() + match = _OPENER.match(line, column) if match is None or (match.group(1)[0] == "`" and "`" in match.group(2)): return None - return match.group(1), len(lead) + return match.group(1), column def _closes(fence: tuple[str, int], line: str) -> bool: """Whether a line closes the fence: a run of its character, at least as long as the opening one, followed by nothing but whitespace, indented at most three columns past the item's content column.""" run, column = fence - match = _FENCE.match(line) + match = _FENCE.match(line.expandtabs(4)) return bool(match and match.group(2)[0] == run[0] and len(match.group(2)) >= len(run) and not match.group(3).strip() and len(match.group(1)) <= column + 3) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 7e0f2e574..d0f165ba3 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -57,6 +57,9 @@ ("1. ```\n # not\n ```\n# Real\n", ["Real"]), # an ordered item ("- ```\n# Real\n```\n# Real2\n", ["Real"]), # the item ends; the ``` at column 0 opens a fence over Real2 ("- use ``` for code\n# Real\n", ["Real"]), # a list item that is prose, not a fence + ("- - ```\n # not\n ```\n# Real\n", ["Real"]), # two markers on one line + ("- ```\n# Real\n", ["Real"]), # five spaces after the marker: indented code, not a fence + ("- ```\n\t# not\n\t```\n# Real\n", ["Real"]), # a tab is four columns of indentation: the item's content ]) def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections From 45f2f4fa6f0c44f51df9fb78bf97ab764561c804 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 13:11:55 -0800 Subject: [PATCH 09/22] fix(format): a fence on a list continuation line stays in its item A fence opened on a line without a list marker, indented to the open item's content column, took column 0. Its dedent check then never ended it, so a real heading after the list stayed body text, and the round trip still passed. Track the open items (outermost first) across lines: a non-blank line ends the items it is indented less than, a marker opens one per marker, and a fence takes the innermost column. Lines are read expanded for the opener, as for the closer. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/guidance_format.py | 54 +++++++++++++++-------- dokimi/tests/test_repo_guidance_format.py | 6 +++ 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 400fb1383..c38e81578 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -56,14 +56,32 @@ def restore(rendered: str) -> str: return "\n".join(out) -def _opened(line: str) -> tuple[str, int] | None: - """The fence a line opens, as (its run, the column its content starts at), or None. A fence may open a list item - (``- ```, ``- - ```) and then its content is indented to the item's column. As in CommonMark, a backtick fence's - info string has no backtick.""" - column = 0 - while (item := _ITEM.match(line, column)) is not None: - column = item.end() - match = _OPENER.match(line, column) +def _marker_columns(line: str) -> tuple[int, ...]: + """The content columns of the list markers that start a line, outermost first (``- - x`` gives two).""" + columns: list[int] = [] + while (item := _ITEM.match(line, columns[-1] if columns else 0)) is not None: + columns.append(item.end()) + return tuple(columns) + + +def _items_after(items: tuple[int, ...], line: str) -> tuple[int, ...]: + """The list items open after a line read outside a fence, outermost first. A blank line keeps them. A non-blank line + ends the items whose content column it is indented less than, and a list marker it starts opens one item per + marker at its content column.""" + if not line.strip(): + return items + expanded = line.expandtabs(4) + indent = len(expanded) - len(expanded.lstrip(" ")) + return tuple(column for column in items if column <= indent) + _marker_columns(line) + + +def _opened(line: str, items: tuple[int, ...]) -> tuple[str, int] | None: + """The fence a line opens, as (its run, the column its content starts at), or None. The fence takes the content + column of the innermost item open at the line (``items``, from `_items_after`), so a fence on a list marker line + (``- ```, ``- - ```) and one on a continuation line both sit inside their item. The line is read expanded, a tab + counting to four columns. As in CommonMark, a backtick fence's info string has no backtick.""" + column = items[-1] if items else 0 + match = _OPENER.match(line.expandtabs(4), column) if match is None or (match.group(1)[0] == "`" and "`" in match.group(2)): return None return match.group(1), column @@ -78,23 +96,23 @@ def _closes(fence: tuple[str, int], line: str) -> bool: and not match.group(3).strip() and len(match.group(1)) <= column + 3) -def _advance(fence: tuple[str, int] | None, line: str) -> tuple[str, int] | None: - """The fence open after a line. A line indented less than the column of the item that opened the fence ends the - item, and the fence with it; the line is then read as any other line.""" +def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str + ) -> tuple[tuple[str, int] | None, tuple[int, ...]]: + """The fence open after a line, and the list items open after it. A line indented less than the column of the item + that opened the fence ends the item, and the fence with it; the line is then read as any other line.""" if fence is not None: expanded = line.expandtabs(4) # a tab is indentation up to the next multiple of four - if line.strip() and len(expanded) - len(expanded.lstrip(" ")) < fence[1]: - fence = None - else: - return None if _closes(fence, line) else fence - return _opened(line) + if not (line.strip() and len(expanded) - len(expanded.lstrip(" ")) < fence[1]): + return (None if _closes(fence, line) else fence), items + items = _items_after(items, line) + return _opened(line, items), items def _render_xml(text: str) -> str: - out, fence = [_ROOT], None + out, fence, items = [_ROOT], None, () open_levels: list[int] = [] for line in text.split("\n"): - fence = _advance(fence, line) + fence, items = _advance(fence, items, line) heading = _HEADING.match(line) if fence is None else None # a line of a fence is body text if heading is None: out.append(line) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index d0f165ba3..4eed96500 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -60,6 +60,12 @@ ("- - ```\n # not\n ```\n# Real\n", ["Real"]), # two markers on one line ("- ```\n# Real\n", ["Real"]), # five spaces after the marker: indented code, not a fence ("- ```\n\t# not\n\t```\n# Real\n", ["Real"]), # a tab is four columns of indentation: the item's content + ("- Example:\n ```\n code\n# Real\n", ["Real"]), # a fence on a continuation line takes the item's column + ("1. Step\n\n ```sh\n x\n# Real\n", ["Real"]), # the same after a blank line, ordered item + ("- Example:\n ```\n code\n ```\n# Real\n", ["Real"]), # closed inside the item: the heading is a section + ("- Example:\n\n```\n# not\n```\n# Real\n", ["Real"]), # a column-0 fence after a blank line ends the item + ("- a\n - b\n ```\n code\n# Real\n", ["Real"]), # a fence on the outer item's line after a nested item + ("- a\n\t```\n```\n# Real\n", []), # a tab-indented continuation fence (a pin: the sections match the spaces form) ]) def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections From a333c8b3104d0427291d3dfc1ca1a57d358caeb0 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 13:56:09 -0800 Subject: [PATCH 10/22] fix(format): annotate the render state so mypy keeps the item tuple type Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/guidance_format.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index c38e81578..5278772bc 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -109,7 +109,9 @@ def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str def _render_xml(text: str) -> str: - out, fence, items = [_ROOT], None, () + out = [_ROOT] + fence: tuple[str, int] | None = None + items: tuple[int, ...] = () open_levels: list[int] = [] for line in text.split("\n"): fence, items = _advance(fence, items, line) From 2c9b57c4871489f04b9564609c14265bb2e74548 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 17:04:29 -0800 Subject: [PATCH 11/22] fix(repo): as-skill collisions refused in preflight; tab after a list marker check_arms refuses a guidance:as-skill arm whose generated skill .claude/skills/-guidance is one of its skills or is tracked by a task's base, before any cell runs, instead of failing cell by cell in preparation. A list marker followed by a tab is read at its expanded columns, as the fence opener is, so the item's fence is recognised; pinned against CommonMark. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/commands/repo_run.py | 4 ++-- dokimi/dokimi/repos/arms.py | 27 +++++++++++++++++++---- dokimi/dokimi/repos/guidance_format.py | 3 ++- dokimi/tests/test_repo_guidance_format.py | 3 +++ dokimi/tests/test_repo_skill_arms.py | 16 ++++++++++++++ 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/dokimi/dokimi/commands/repo_run.py b/dokimi/dokimi/commands/repo_run.py index 4bd110ada..1fc63c1fa 100644 --- a/dokimi/dokimi/commands/repo_run.py +++ b/dokimi/dokimi/commands/repo_run.py @@ -193,7 +193,7 @@ def cmd_repo_tasks_check(args: argparse.Namespace) -> None: ctx, _ = _context(args, out) tasks = load_tasks(ctx.content_dir, ctx.manifest, args.task) arms = [a for spec in args.arm or () for a in spec.split(",") if a] - check_arms([parse_arm(a) for a in arms], ctx.content_dir, tasks, ctx.git_dir, ctx.manifest.guidance_globs) # skill paths exist, catalogs hold each expected_skill + check_arms([parse_arm(a) for a in arms], ctx.content_dir, tasks, ctx.git_dir, ctx.manifest.guidance_globs, ctx.manifest.id) # skill paths exist, catalogs hold each expected_skill if arms: print(f"{len(arms)} arm(s) ok: {', '.join(arms)}", file=sys.stderr) print(f"{len(tasks)} task(s), image {ctx.image}{' (warm: with network)' if args.warm else ''}", @@ -282,7 +282,7 @@ def _run_locked(args: argparse.Namespace, arms: list[str], batch_dir: Path, lock lock.enter_context(batch_lock(batch_dir)) # before _context, which rewrites _eval/content ctx, _ = _context(args, batch_dir, pin=True) tasks = load_tasks(ctx.content_dir, ctx.manifest, args.task) - check_arms(arms, ctx.content_dir, tasks, ctx.git_dir, ctx.manifest.guidance_globs) # skill paths exist, catalogs hold each expected_skill + check_arms(arms, ctx.content_dir, tasks, ctx.git_dir, ctx.manifest.guidance_globs, ctx.manifest.id) # skill paths exist, catalogs hold each expected_skill check_mcp_configs(ctx.git_dir, tasks, profile, args.tools) # a malformed .mcp.json fails here, not in every cell skill_pin = pin_skills(batch_dir, arms, ctx.content_dir) # a resume may not swap an arm's skills validate_tasks(tasks) # refuse malformed host requirements before containers start diff --git a/dokimi/dokimi/repos/arms.py b/dokimi/dokimi/repos/arms.py index 5275acd3c..3af866c9b 100644 --- a/dokimi/dokimi/repos/arms.py +++ b/dokimi/dokimi/repos/arms.py @@ -181,13 +181,15 @@ def _base_skill_files(git_dir: Path, base: str) -> tuple[list[str], list[str]]: def check_arms(arms: list[Any], content_dir: Path, tasks: list[Any], git_dir: Path | None = None, - guidance_globs: tuple[str, ...] | list[str] = ()) -> None: + guidance_globs: tuple[str, ...] | list[str] = (), repo_id: str | None = None) -> None: """Find the skills of every parsed arm before anything runs; a catalog must hold each task's ``expected_skill``. Two arms that would share a name or a result directory are refused, and so is a skill the base of a task (in ``git_dir``) already ships, which preparing the workspace would refuse cell by cell. - As preparation does, a ``guidance:none`` or pinned-revision arm first drops the base's files that match - ``guidance_globs``: only what is left under the skill's directory counts as shipped. A revision arm then - writes back its revision's files that match them, so those count as shipped too. The files of a ``patch:`` + As preparation does, a ``guidance:none``, ``guidance:as-skill`` or pinned-revision arm first drops the base's + files that match ``guidance_globs``: only what is left under the skill's directory counts as shipped. A revision + arm then writes back its revision's files that match them, so those count as shipped too. A ``guidance:as-skill`` + arm writes its own skill, ``.claude/skills/-guidance``: a skill of the arm with that name, or a file the + base tracks under it, is refused here (``repo_id`` given). The files of a ``patch:`` arm's patch and of a task's ``setup_patches`` are not read: a skill directory they add is refused only in preparation.""" cells: dict[str, str] = {} # cell-name slug of an arm -> the arm spec that owns it @@ -197,6 +199,8 @@ def check_arms(arms: list[Any], content_dir: Path, tasks: list[Any], git_dir: Pa if cells.setdefault(cell, arm.spec) != arm.spec: raise ArmError(f"arms {cells[cell]!r} and {arm.spec!r} would share one result directory ({cell})") skills = resolve_skills(arm, content_dir) + if repo_id is not None and arm.patch is None and arm.base == "guidance:as-skill": + _check_generated_skill(arm, skills, repo_id, tasks, git_dir) drops = arm.patch is None and (arm.base in ("guidance:none", "guidance:as-skill") or arm.rev is not None) rev = (arm.rev_sha or arm.rev) if arm.patch is None else None if skills and git_dir is not None: @@ -206,6 +210,21 @@ def check_arms(arms: list[Any], content_dir: Path, tasks: list[Any], git_dir: Pa _check_expected_skills(arm, tasks, {s.name for s in skills}) +def _check_generated_skill(arm: Any, skills: list[Skill], repo_id: str, tasks: list[Any], git_dir: Path | None) -> None: + """Refuse a ``guidance:as-skill`` arm whose generated skill directory is a skill of the arm, or is tracked by the + base of a task: preparation writes that directory first, so it would fail cell by cell, not here.""" + target = f"{SKILLS_DIR}/{slug(f'{repo_id}-guidance')}" + for skill in skills: + if skill.target == target: + raise ArmError(f"arm {arm.spec!r}: its generated guidance skill {target} is also one of its skills") + if git_dir is None: + return + for task in tasks: + for path in _base_skill_files(git_dir, task.base)[0]: + if path == target or path.startswith(target + "/"): + raise ArmError(f"arm {arm.spec!r}: the base of task {task.id} already has {target}") + + def _check_shipped(arm: Any, skills: list[Skill], task: Any, git_dir: Path, shipped: dict[str, tuple[list[str], list[str]]], dropping: tuple[bool, str | None, tuple[str, ...] | list[str]]) -> None: diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 5278772bc..b6a938afb 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -59,7 +59,8 @@ def restore(rendered: str) -> str: def _marker_columns(line: str) -> tuple[int, ...]: """The content columns of the list markers that start a line, outermost first (``- - x`` gives two).""" columns: list[int] = [] - while (item := _ITEM.match(line, columns[-1] if columns else 0)) is not None: + expanded = line.expandtabs(4) # a tab after a marker is spaces up to the next column, as in _opened + while (item := _ITEM.match(expanded, columns[-1] if columns else 0)) is not None: columns.append(item.end()) return tuple(columns) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 4eed96500..3198d48c1 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -65,6 +65,9 @@ ("- Example:\n ```\n code\n ```\n# Real\n", ["Real"]), # closed inside the item: the heading is a section ("- Example:\n\n```\n# not\n```\n# Real\n", ["Real"]), # a column-0 fence after a blank line ends the item ("- a\n - b\n ```\n code\n# Real\n", ["Real"]), # a fence on the outer item's line after a nested item + ("-\t```\n echo hi\n ```\n# Real\n", ["Real"]), # a tab after a bullet is four columns: the item's fence + ("1.\t```\n echo hi\n ```\n# Real\n", ["Real"]), # the same after an ordered marker + ("-\t```\n echo hi\n ```\n# Real\n", []), # 2 spaces is under the item's column 4: the item ends, as in CommonMark ("- a\n\t```\n```\n# Real\n", []), # a tab-indented continuation fence (a pin: the sections match the spaces form) ]) def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): diff --git a/dokimi/tests/test_repo_skill_arms.py b/dokimi/tests/test_repo_skill_arms.py index 1994abd9f..b69a034c6 100644 --- a/dokimi/tests/test_repo_skill_arms.py +++ b/dokimi/tests/test_repo_skill_arms.py @@ -473,6 +473,22 @@ def test_a_nested_name_alone_is_not_the_skill_name(tmp_path): assert [s.name for s in resolve_skills(parse_arm("skill:skills/lint"), tmp_path)] == ["lint"] +@pytest.mark.parametrize("arm", ["guidance:as-skill+skill:skills/toy-guidance", "guidance:as-skill+skill-catalog:skills"]) +def test_an_as_skill_arm_with_a_skill_named_like_its_generated_one_is_refused_before_any_cell(tmp_path, arm): + _skill(tmp_path, "skills/toy-guidance", "toy-guidance") + with pytest.raises(ArmError, match="generated guidance skill .claude/skills/toy-guidance"): + check_arms([parse_arm(arm)], tmp_path, [], None, (), "toy") + check_arms([parse_arm(arm)], tmp_path, [], None, (), None) # without the repo id the name is not known: no check + + +def test_an_as_skill_arm_refuses_a_base_that_ships_its_generated_skill_before_any_cell(tmp_path): + _skill(tmp_path / "toy", ".claude/skills/toy-guidance/SKILL.md", "toy-guidance") # a SKILL.md, no guidance glob + root, base = make_repo(tmp_path) + task = Task("t", {"base": base}, root) + with pytest.raises(ArmError, match="base of task t already has .claude/skills/toy-guidance"): + check_arms([parse_arm("guidance:as-skill")], root / ".agent-eval", [task], root / ".git", (), "toy") + + def test_a_skill_the_task_base_already_ships_is_refused_before_any_cell(tmp_path, capsys, monkeypatch): monkeypatch.setattr("dokimi.repos.container.DockerRunner", lambda *a, **k: FakeRunner()) _skill(tmp_path / "toy", ".claude/skills/lint", "lint") From 9b5e4f2df199d922121cd9d348072b35326b078e Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 20:28:54 -0800 Subject: [PATCH 12/22] fix(repo): a lazy paragraph continuation keeps the list item open for fence tracking Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/guidance_format.py | 27 ++++++++++++++--------- dokimi/tests/test_repo_guidance_format.py | 3 +++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index b6a938afb..2a2fd2299 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -23,6 +23,7 @@ _FENCE = re.compile(r"^( *)(`{3,}|~{3,})(.*)$") # the indentation, a run of ``` or ~~~, and what follows it _ITEM = re.compile(r" *(?:[-*+]|\d{1,9}[.)]) {1,4}(?=\S)") # a list marker and the one to four spaces after it _OPENER = re.compile(r" {0,3}(`{3,}|~{3,})(.*)$") # up to three spaces, then a run that opens a fence +_BLOCK = re.compile(r" *(?:#{1,6}(?: |$)|>|([-*_])( *\1){2,} *$)") # a heading, quote or rule: a line that starts a block _OPEN = re.compile(r'^
$') _ESCAPES = {"&": "&", "<": "<", ">": ">", '"': """} _UNESCAPES = {v: k for k, v in _ESCAPES.items()} @@ -65,14 +66,16 @@ def _marker_columns(line: str) -> tuple[int, ...]: return tuple(columns) -def _items_after(items: tuple[int, ...], line: str) -> tuple[int, ...]: +def _items_after(items: tuple[int, ...], line: str, lazy: bool = False) -> tuple[int, ...]: """The list items open after a line read outside a fence, outermost first. A blank line keeps them. A non-blank line ends the items whose content column it is indented less than, and a list marker it starts opens one item per - marker at its content column.""" + marker at its content column. A ``lazy`` line (one that continues the paragraph before it) ends nothing.""" if not line.strip(): return items expanded = line.expandtabs(4) indent = len(expanded) - len(expanded.lstrip(" ")) + if lazy and not _marker_columns(line) and not _BLOCK.match(expanded) and not _OPENER.match(expanded): + return items return tuple(column for column in items if column <= indent) + _marker_columns(line) @@ -97,25 +100,29 @@ def _closes(fence: tuple[str, int], line: str) -> bool: and not match.group(3).strip() and len(match.group(1)) <= column + 3) -def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str - ) -> tuple[tuple[str, int] | None, tuple[int, ...]]: - """The fence open after a line, and the list items open after it. A line indented less than the column of the item - that opened the fence ends the item, and the fence with it; the line is then read as any other line.""" +def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str, para: bool = False + ) -> tuple[tuple[str, int] | None, tuple[int, ...], bool]: + """The fence open after a line, the list items open after it, and whether the line is paragraph text (which a + following less indented line may continue lazily). A line indented less than the column of the item that opened + the fence ends the item, and the fence with it; the line is then read as any other line.""" if fence is not None: expanded = line.expandtabs(4) # a tab is indentation up to the next multiple of four if not (line.strip() and len(expanded) - len(expanded.lstrip(" ")) < fence[1]): - return (None if _closes(fence, line) else fence), items - items = _items_after(items, line) - return _opened(line, items), items + return (None if _closes(fence, line) else fence), items, False + items = _items_after(items, line, para and bool(items)) + opened = _opened(line, items) + text = line.expandtabs(4).strip() + return opened, items, opened is None and bool(text) and not _BLOCK.match(text) def _render_xml(text: str) -> str: out = [_ROOT] fence: tuple[str, int] | None = None items: tuple[int, ...] = () + para = False open_levels: list[int] = [] for line in text.split("\n"): - fence, items = _advance(fence, items, line) + fence, items, para = _advance(fence, items, line, para) heading = _HEADING.match(line) if fence is None else None # a line of a fence is body text if heading is None: out.append(line) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 3198d48c1..1b8147aac 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -69,6 +69,9 @@ ("1.\t```\n echo hi\n ```\n# Real\n", ["Real"]), # the same after an ordered marker ("-\t```\n echo hi\n ```\n# Real\n", []), # 2 spaces is under the item's column 4: the item ends, as in CommonMark ("- a\n\t```\n```\n# Real\n", []), # a tab-indented continuation fence (a pin: the sections match the spaces form) + ("- Example:\ncontinued text\n ```\n code\n# Real\n", ["Real"]), # a lazy paragraph line keeps the item open + ("- a\nlazy\n```\n# not\n```\n# Real\n", ["Real"]), # a fence at column 0 is a block start, not a lazy line + ("- a\nlazy\n# Real\n", ["Real"]), # a heading is a block start too ]) def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections From 78fb39bf39ef9b271bddbed2c708d22697a0d857 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Mon, 2 Nov 2026 11:23:53 +0000 Subject: [PATCH 13/22] test(repo): pin the lazy-line exclusions in fence tracking Signed-off-by: Anton Volkov --- dokimi/tests/test_repo_guidance_format.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 1b8147aac..b4dc1bc6f 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -72,6 +72,10 @@ ("- Example:\ncontinued text\n ```\n code\n# Real\n", ["Real"]), # a lazy paragraph line keeps the item open ("- a\nlazy\n```\n# not\n```\n# Real\n", ["Real"]), # a fence at column 0 is a block start, not a lazy line ("- a\nlazy\n# Real\n", ["Real"]), # a heading is a block start too + ("- a\n> quote\n ```\n# Real\n", []), # a block quote is a block start: the item ends, the fence opens at top level + ("- a\n---\n ```\n# x\n", []), # a rule is a block start, not a lazy line + ("- a\n# H\n ```\n# x\n", ["H"]), # a heading ends the item too: only H is a section, the fence holds x + ("- a\n\nlazy\n ```\n# x\n", []), # a blank line ends the item: the next line is not a lazy continuation ]) def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections From 622017c6a3c2ce7cbdd604ea7e2d2c7e86eaade6 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 08:23:57 -0800 Subject: [PATCH 14/22] fix(repo): track empty list items and rules in guidance fences The fence tracker of guidance:format=xml now follows CommonMark where the review threads of #333 showed it did not: an empty list item opens its content column one past the marker, a thematic break is not a list item, an ordered marker only interrupts a paragraph when numbered 1, indented code is not paragraph text, and a blank line ends an empty item. check_arms also counts the generated guidance skill of a guidance:as-skill arm as installed, so an expected_skill naming it is accepted. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/arms.py | 4 +- dokimi/dokimi/repos/guidance_format.py | 69 ++++++++++++++++------- dokimi/tests/test_repo_guidance_format.py | 10 ++++ dokimi/tests/test_repo_skill_arms.py | 44 +++++++++++++++ 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/dokimi/dokimi/repos/arms.py b/dokimi/dokimi/repos/arms.py index 3af866c9b..4da9fad5e 100644 --- a/dokimi/dokimi/repos/arms.py +++ b/dokimi/dokimi/repos/arms.py @@ -199,15 +199,17 @@ def check_arms(arms: list[Any], content_dir: Path, tasks: list[Any], git_dir: Pa if cells.setdefault(cell, arm.spec) != arm.spec: raise ArmError(f"arms {cells[cell]!r} and {arm.spec!r} would share one result directory ({cell})") skills = resolve_skills(arm, content_dir) + names = {s.name for s in skills} if repo_id is not None and arm.patch is None and arm.base == "guidance:as-skill": _check_generated_skill(arm, skills, repo_id, tasks, git_dir) + names.add(slug(f"{repo_id}-guidance")) # the skill preparation installs beside the catalog's drops = arm.patch is None and (arm.base in ("guidance:none", "guidance:as-skill") or arm.rev is not None) rev = (arm.rev_sha or arm.rev) if arm.patch is None else None if skills and git_dir is not None: for task in tasks: _check_shipped(arm, skills, task, git_dir, shipped, (drops, rev, guidance_globs)) if any(kind == "skill-catalog" for kind, _ in arm.skills): - _check_expected_skills(arm, tasks, {s.name for s in skills}) + _check_expected_skills(arm, tasks, names) def _check_generated_skill(arm: Any, skills: list[Skill], repo_id: str, tasks: list[Any], git_dir: Path | None) -> None: diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 2a2fd2299..675d9a79e 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -21,7 +21,8 @@ _ROOT, _ROOT_CLOSE, _CLOSE = "", "", "
" _HEADING = re.compile(r"^(#{1,6}) (\S[^\r]*)$") # canonical only: one space, no CR; anything else is body text _FENCE = re.compile(r"^( *)(`{3,}|~{3,})(.*)$") # the indentation, a run of ``` or ~~~, and what follows it -_ITEM = re.compile(r" *(?:[-*+]|\d{1,9}[.)]) {1,4}(?=\S)") # a list marker and the one to four spaces after it +_ITEM = re.compile(r" *(?:[-*+]|(\d{1,9})[.)])(?: {1,4}(?=\S)|(?=\s*$))") # a list marker (its number if ordered), then 1-4 spaces and content, or nothing (an empty item) +_RULE = re.compile(r" *([-*_])(?: *\1){2,} *$") # a thematic break: no list item, though ``- - -`` looks like two _OPENER = re.compile(r" {0,3}(`{3,}|~{3,})(.*)$") # up to three spaces, then a run that opens a fence _BLOCK = re.compile(r" *(?:#{1,6}(?: |$)|>|([-*_])( *\1){2,} *$)") # a heading, quote or rule: a line that starts a block _OPEN = re.compile(r'^
$') @@ -57,26 +58,45 @@ def restore(rendered: str) -> str: return "\n".join(out) -def _marker_columns(line: str) -> tuple[int, ...]: - """The content columns of the list markers that start a line, outermost first (``- - x`` gives two).""" - columns: list[int] = [] +def _marker_columns(line: str, para: bool = False) -> tuple[int, ...]: + """The content columns of the list markers that start a line, outermost first (``- - x`` gives two). A thematic + break starts none. After paragraph text (``para``) the outermost marker starts an item only if CommonMark lets it + interrupt a paragraph: a bullet with content, or an ordered marker numbered 1; otherwise the line is paragraph + text and no marker starts.""" expanded = line.expandtabs(4) # a tab after a marker is spaces up to the next column, as in _opened + if _RULE.match(expanded): + return () + columns: list[int] = [] while (item := _ITEM.match(expanded, columns[-1] if columns else 0)) is not None: - columns.append(item.end()) + empty = not expanded[item.end():].strip() # an empty item's content starts one column past its marker + if not columns and para and (empty or (item.group(1) is not None and int(item.group(1)) != 1)): + return () + columns.append(item.end() + (1 if empty else 0)) return tuple(columns) -def _items_after(items: tuple[int, ...], line: str, lazy: bool = False) -> tuple[int, ...]: +def _markers(items: tuple[int, ...], line: str, para: bool) -> tuple[int, ...]: + """The list markers a line starts, given the items open before it. The interrupt rule of `_marker_columns` applies + only when the paragraph is inside the containers the line continues: an item the line is indented less than is + not continued, and a marker that follows its paragraph there starts a list.""" + expanded = line.expandtabs(4) + indent = len(expanded) - len(expanded.lstrip(" ")) + return _marker_columns(line, para and (not items or indent >= items[-1])) + + +def _items_after(items: tuple[int, ...], line: str, para: bool = False) -> tuple[int, ...]: """The list items open after a line read outside a fence, outermost first. A blank line keeps them. A non-blank line ends the items whose content column it is indented less than, and a list marker it starts opens one item per - marker at its content column. A ``lazy`` line (one that continues the paragraph before it) ends nothing.""" + marker at its content column. A line that continues paragraph text (``para``) ends nothing, unless it starts a + block (a heading, quote or rule, or a fence) or a list item.""" if not line.strip(): return items expanded = line.expandtabs(4) indent = len(expanded) - len(expanded.lstrip(" ")) - if lazy and not _marker_columns(line) and not _BLOCK.match(expanded) and not _OPENER.match(expanded): + markers = _markers(items, line, para) + if not markers and para and not _BLOCK.match(expanded) and not _OPENER.match(expanded): return items - return tuple(column for column in items if column <= indent) + _marker_columns(line) + return tuple(column for column in items if column <= indent) + markers def _opened(line: str, items: tuple[int, ...]) -> tuple[str, int] | None: @@ -100,29 +120,38 @@ def _closes(fence: tuple[str, int], line: str) -> bool: and not match.group(3).strip() and len(match.group(1)) <= column + 3) -def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str, para: bool = False - ) -> tuple[tuple[str, int] | None, tuple[int, ...], bool]: - """The fence open after a line, the list items open after it, and whether the line is paragraph text (which a - following less indented line may continue lazily). A line indented less than the column of the item that opened - the fence ends the item, and the fence with it; the line is then read as any other line.""" +def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str, para: bool = False, + empty: bool = False) -> tuple[tuple[str, int] | None, tuple[int, ...], bool, bool]: + """The fence open after a line, the list items open after it, whether the line is paragraph text (which a + following less indented line may continue lazily), and whether it is an empty list item. A line indented less than + the column of the item that opened the fence ends the item, and the fence with it; the line is then read as any + other line. An indented code block is not paragraph text. A blank line ends an empty item (CommonMark: an item + begins with at most one blank line).""" if fence is not None: expanded = line.expandtabs(4) # a tab is indentation up to the next multiple of four if not (line.strip() and len(expanded) - len(expanded.lstrip(" ")) < fence[1]): - return (None if _closes(fence, line) else fence), items, False - items = _items_after(items, line, para and bool(items)) + return (None if _closes(fence, line) else fence), items, False, False + if not line.strip(): + return None, (items[:-1] if empty else items), False, False + markers = _markers(items, line, para) + items = _items_after(items, line, para) opened = _opened(line, items) - text = line.expandtabs(4).strip() - return opened, items, opened is None and bool(text) and not _BLOCK.match(text) + expanded = line.expandtabs(4) + text = expanded[markers[-1]:].strip() if markers else expanded.strip() + indent = len(expanded) - len(expanded.lstrip(" ")) + code = not para and indent - (items[-1] if items else 0) >= 4 # four columns past the content column: code + return (opened, items, opened is None and bool(text) and not _BLOCK.match(text) and not code, + bool(markers) and not text) def _render_xml(text: str) -> str: out = [_ROOT] fence: tuple[str, int] | None = None items: tuple[int, ...] = () - para = False + para = empty = False open_levels: list[int] = [] for line in text.split("\n"): - fence, items, para = _advance(fence, items, line, para) + fence, items, para, empty = _advance(fence, items, line, para, empty) heading = _HEADING.match(line) if fence is None else None # a line of a fence is body text if heading is None: out.append(line) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index b4dc1bc6f..86c4f212c 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -76,6 +76,16 @@ ("- a\n---\n ```\n# x\n", []), # a rule is a block start, not a lazy line ("- a\n# H\n ```\n# x\n", ["H"]), # a heading ends the item too: only H is a section, the fence holds x ("- a\n\nlazy\n ```\n# x\n", []), # a blank line ends the item: the next line is not a lazy continuation + ("-\n ```\n# Real\n", ["Real"]), # an empty bullet is an item: its fence is at the content column 2 + ("1.\n ```\n# Real\n", ["Real"]), # an empty ordered item: its content column is 3 + ("-\n ```\n```\n# Real\n", []), # the column-0 fence ends the empty item; the hash line is code + ("-\n ```\n# H\n```\n# Real\n", ["H"]), # H ends the item and its fence; the fence at column 0 holds Real + ("-\n\n ```\n# Real\n", []), # an empty item ends at a blank line: the indented fence is top-level + ("- - -\n ```\n# not a heading\n ```\n", []), # a thematic break is no list item + ("* * *\n ```\n# not a heading\n ```\n", []), # the same with asterisks + ("paragraph\n2. ```\n ```\n# Real\n", []), # a numbered marker other than 1 cannot interrupt a paragraph + ("- a\n2. x\n ```\n# Real\n", []), # 2. x starts a new list (the item is not continued), so the fence is top-level + ("- a\n\n code\noutside\n ```\n# not a heading\n", []), # indented code is not paragraph text ]) def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections diff --git a/dokimi/tests/test_repo_skill_arms.py b/dokimi/tests/test_repo_skill_arms.py index b69a034c6..04eeea6e2 100644 --- a/dokimi/tests/test_repo_skill_arms.py +++ b/dokimi/tests/test_repo_skill_arms.py @@ -804,3 +804,47 @@ def test_the_as_skill_arm_removes_the_base_skill_it_packages_so_it_is_not_refuse check_arms([parse_arm("guidance:as-skill+skill:skills/lint")], content, tasks, root / ".git", globs) with pytest.raises(ArmError, match="already has .claude/skills/lint"): # the same base under guidance:raw check_arms([parse_arm("guidance:raw+skill:skills/lint")], content, tasks, root / ".git", globs) + + +def test_a_catalog_arm_with_as_skill_accepts_an_expected_skill_naming_the_generated_skill(tmp_path): + _skill(tmp_path, "skills/lint", "lint") # the catalog holds lint only; preparation installs toy-guidance beside it + task = Task("t", {"expected_skill": "toy-guidance"}, tmp_path) + check_arms([parse_arm("guidance:as-skill+skill-catalog:skills")], tmp_path, [task], None, (), "toy") + + +def test_a_skill_named_like_the_generated_one_is_accepted_when_the_arm_is_not_as_skill(tmp_path): + _skill(tmp_path, "skills/toy-guidance", "toy-guidance") + check_arms([parse_arm("guidance:none+skill:skills/toy-guidance")], tmp_path, [], None, (), "toy") + + +def test_an_as_skill_arm_refuses_a_base_that_tracks_a_plain_file_at_its_generated_skill(tmp_path): + write(tmp_path / "toy", ".claude/skills/toy-guidance", "not a skill\n") + root, base = make_repo(tmp_path) + task = Task("t", {"base": base}, root) + with pytest.raises(ArmError, match="base of task t already has .claude/skills/toy-guidance"): + check_arms([parse_arm("guidance:as-skill")], root / ".agent-eval", [task], root / ".git", (), "toy") + + +def test_an_as_skill_arm_accepts_a_base_skill_whose_name_only_starts_with_the_generated_one(tmp_path): + _skill(tmp_path / "toy", ".claude/skills/toy-guidance2", "toy-guidance2") + root, base = make_repo(tmp_path) + task = Task("t", {"base": base}, root) + check_arms([parse_arm("guidance:as-skill")], root / ".agent-eval", [task], root / ".git", (), "toy") + + +def test_the_generated_skill_name_is_the_slug_of_the_repo_id(tmp_path): + _skill(tmp_path, "skills/my_repo-guidance", "my_repo-guidance") + with pytest.raises(ArmError, match="generated guidance skill .claude/skills/my_repo-guidance"): + check_arms([parse_arm("guidance:as-skill+skill:skills/my_repo-guidance")], tmp_path, [], None, (), "my repo") + + +def test_tasks_check_refuses_an_as_skill_arm_whose_generated_skill_the_arm_also_ships(setup, capsys): + _skill(setup["root"], ".agent-eval/skills/toy-guidance", "toy-guidance") + git(setup["root"], "add", "-A") + git(setup["root"], "commit", "-qm", "generated skill") + argv = ["repo", "tasks", "check", f"{setup['root']}@HEAD", "--out", str(setup["out"] / "chk"), + "--cache-root", str(setup["out"] / "caches"), "--arm", "guidance:as-skill+skill:skills/toy-guidance"] + args = build_parser().parse_args(argv) + with pytest.raises(SystemExit): + args.func(args) + assert "its generated guidance skill .claude/skills/toy-guidance is also one of its skills" in capsys.readouterr().err From d89a24097c5099d3e453bc5dc035b85962c89480 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 08:26:23 -0800 Subject: [PATCH 15/22] test(repo): pin the repo run call site of the generated-skill check The refusal of a guidance:as-skill arm whose generated skill the arm also ships was tested through repo tasks check only; repo run passes the repo id on its own call, and dropping it there passed every test. Add a repo run test that asserts the refusal before any cell. Signed-off-by: Nikolay Petrov --- dokimi/tests/test_repo_skill_arms.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dokimi/tests/test_repo_skill_arms.py b/dokimi/tests/test_repo_skill_arms.py index 04eeea6e2..6a0b9f925 100644 --- a/dokimi/tests/test_repo_skill_arms.py +++ b/dokimi/tests/test_repo_skill_arms.py @@ -848,3 +848,13 @@ def test_tasks_check_refuses_an_as_skill_arm_whose_generated_skill_the_arm_also_ with pytest.raises(SystemExit): args.func(args) assert "its generated guidance skill .claude/skills/toy-guidance is also one of its skills" in capsys.readouterr().err + + +def test_repo_run_refuses_an_as_skill_arm_whose_generated_skill_the_arm_also_ships(setup, capsys): + _skill(setup["root"], ".agent-eval/skills/toy-guidance", "toy-guidance") + git(setup["root"], "add", "-A") + git(setup["root"], "commit", "-qm", "generated skill") + with pytest.raises(SystemExit): + _run(setup, "--arm", "guidance:as-skill+skill:skills/toy-guidance", "--task", "fix_lint") + assert "its generated guidance skill .claude/skills/toy-guidance is also one of its skills" in capsys.readouterr().err + assert not list((setup["out"] / "b1").glob("*/meta.json")) # refused before any cell From 1531c45406723bd1d897c8a5fc3fc108782aca44 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 09:37:38 -0800 Subject: [PATCH 16/22] fix(repo): a lazy line continues a block quote's paragraph inside a list item A quote whose content is paragraph text sets the paragraph state, so a less indented line that follows it lazily stays in the list item. Before, the quote marker counted as a block start and dropped the list, so a fence at column 0 opened over the heading after it. Signed-off-by: Nikolay Petrov --- dokimi/dokimi/repos/guidance_format.py | 24 +++++++++++++++++------ dokimi/tests/test_repo_guidance_format.py | 3 +++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 675d9a79e..67de9f2c1 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -25,6 +25,7 @@ _RULE = re.compile(r" *([-*_])(?: *\1){2,} *$") # a thematic break: no list item, though ``- - -`` looks like two _OPENER = re.compile(r" {0,3}(`{3,}|~{3,})(.*)$") # up to three spaces, then a run that opens a fence _BLOCK = re.compile(r" *(?:#{1,6}(?: |$)|>|([-*_])( *\1){2,} *$)") # a heading, quote or rule: a line that starts a block +_QUOTE_MARK = re.compile(r" *> ?") # one ``>`` of a block quote, and the space after it _OPEN = re.compile(r'^
$') _ESCAPES = {"&": "&", "<": "<", ">": ">", '"': """} _UNESCAPES = {v: k for k, v in _ESCAPES.items()} @@ -120,13 +121,23 @@ def _closes(fence: tuple[str, int], line: str) -> bool: and not match.group(3).strip() and len(match.group(1)) <= column + 3) +def _quoted_paragraph(text: str) -> bool: + """Whether a block quote's content is paragraph text: after its ``>`` markers it is not a heading, rule, fence or + list item. Such a quote is paragraph text too, so a following line that continues it lazily stays in its item.""" + inner, quoted = text, False + while (mark := _QUOTE_MARK.match(inner)) is not None: + inner, quoted = inner[mark.end():], True + return (quoted and bool(inner.strip()) and not _BLOCK.match(inner) and not _OPENER.match(inner) + and _ITEM.match(inner) is None) + + def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str, para: bool = False, empty: bool = False) -> tuple[tuple[str, int] | None, tuple[int, ...], bool, bool]: - """The fence open after a line, the list items open after it, whether the line is paragraph text (which a - following less indented line may continue lazily), and whether it is an empty list item. A line indented less than - the column of the item that opened the fence ends the item, and the fence with it; the line is then read as any - other line. An indented code block is not paragraph text. A blank line ends an empty item (CommonMark: an item - begins with at most one blank line).""" + """The fence open after a line, the list items open after it, whether the line is paragraph text (a block quote's + paragraph too, which a following less indented line may continue lazily), and whether it is an empty list item. + A line indented less than the column of the item that opened the fence ends the item, and the fence with it; the + line is then read as any other line. An indented code block is not paragraph text. A blank line ends an empty item + (CommonMark: an item begins with at most one blank line).""" if fence is not None: expanded = line.expandtabs(4) # a tab is indentation up to the next multiple of four if not (line.strip() and len(expanded) - len(expanded.lstrip(" ")) < fence[1]): @@ -140,7 +151,8 @@ def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str, p text = expanded[markers[-1]:].strip() if markers else expanded.strip() indent = len(expanded) - len(expanded.lstrip(" ")) code = not para and indent - (items[-1] if items else 0) >= 4 # four columns past the content column: code - return (opened, items, opened is None and bool(text) and not _BLOCK.match(text) and not code, + return (opened, items, opened is None and bool(text) and (not _BLOCK.match(text) or _quoted_paragraph(text)) + and not code, bool(markers) and not text) diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 86c4f212c..3b6d9400e 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -86,6 +86,9 @@ ("paragraph\n2. ```\n ```\n# Real\n", []), # a numbered marker other than 1 cannot interrupt a paragraph ("- a\n2. x\n ```\n# Real\n", []), # 2. x starts a new list (the item is not continued), so the fence is top-level ("- a\n\n code\noutside\n ```\n# not a heading\n", []), # indented code is not paragraph text + ("- > quoted\nlazy\n ```\n# Real\n", ["Real"]), # a lazy line continues the quoted paragraph in the item + ("> quoted\nlazy\n ```\n# Real\n", []), # the same at top level: the fence opens and holds Real + ("- > # H\nlazy\n ```\n# x\n", []), # a quoted heading is no paragraph: the lazy line ends the item ]) def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections From cbf56336f17459334bcb33cfe7b4423f600fa99d Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Mon, 2 Nov 2026 10:20:30 -0800 Subject: [PATCH 17/22] fix(repo): review fixes for the guidance format and as-skill arms - a quoted indented code line does not continue a paragraph lazily - a YAML front matter block stays verbatim above the guidance root - a heading inside a multi-line HTML comment is body text - guidance:as-skill: its skill is measured as guidance_skill_loaded, not in skills - the generated skill name follows the Agent Skills name rule (dots, underscores, length) - a base linking .claude or .claude/skills is refused by check_arms - guidance_links_kept and guidance_skill documented in the contract Signed-off-by: Nikolay Petrov --- ...31_120000_napetrov_guidance-format-arms.md | 2 +- dokimi/docs/repo-evaluation-contract.md | 7 ++- dokimi/dokimi/repos/arms.py | 49 ++++++++++++++----- dokimi/dokimi/repos/guidance_format.py | 45 +++++++++++++---- dokimi/dokimi/repos/runner.py | 3 ++ dokimi/dokimi/repos/workspace.py | 8 ++- dokimi/tests/test_repo_guidance_format.py | 39 +++++++++++++-- dokimi/tests/test_repo_skill_arms.py | 39 +++++++++++++-- 8 files changed, 153 insertions(+), 39 deletions(-) diff --git a/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md b/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md index 81b329bf8..082c7642c 100644 --- a/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md +++ b/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md @@ -1,3 +1,3 @@ ### Added -- **`repo run` arms `guidance:format=xml` and `guidance:as-skill`** — the guidance files of the base, rendered as XML tags (each Markdown heading becomes a `
` element, checked to restore to the original file exactly), or moved into one skill directory (`.claude/skills/-guidance/SKILL.md`). Both compare the same guidance content in another shape under the same model; `guidance:format=md` is refused as a duplicate of `guidance:raw`, and POML is not offered yet. +- **`repo run` arms `guidance:format=xml` and `guidance:as-skill`** — the guidance files of the base, rendered as XML tags (each Markdown heading becomes a `
` element, checked to restore to the original file exactly), or moved into one skill directory (`.claude/skills/-guidance/SKILL.md`, the repo id made into an Agent Skills name). Both compare the same guidance content in another shape under the same model; `guidance:format=md` is refused as a duplicate of `guidance:raw`, and POML is not offered yet. diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index e6d688524..e489b0867 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -397,7 +397,9 @@ grader diffs the agent's changes from `start_sha`. With the arm come files with no counterpart at ``); for `patch:` an `arm_patch` object (the file's absolute `path` and full `sha256`, `added`: the files the patch added that neither the guidance globs nor `.mcp.json` match, and `mcp_servers`: the servers -the patch added to the `.mcp.json` at the root, read before the agent runs). `args_if_present` lists the profile's +the patch added to the `.mcp.json` at the root, read before the agent runs); for `guidance:format=` a +`guidance_links_kept` list of the guidance links that keep their raw bytes; for `guidance:as-skill` a +`guidance_skill` object (`name`, `path`, `sha256`) that is not in `skills`. `args_if_present` lists the profile's `args_if_present` files the cell's command line carries (§6): `[]` when the workspace has none of them, and always outside the `all` tool mode. @@ -484,6 +486,9 @@ the first one used; `null` when the task names none). When the trace could not be read (the harness never started), `measured` is `false` and `skill_invoked` and `skill_selected_correct` are `null`: the usage is unknown, not "did not use the skill". `repo report` leaves such cells out of its skill counts. +A `guidance:as-skill` cell has no `skills` object for its generated skill: its +usage is `guidance_skill_loaded` (`true` when the agent called that skill or read +its `SKILL.md`, `false` when not, `null` when the trace could not be read). For a `patch:` arm with `added` files or `mcp_servers`, `result.json` has an `uptake` object: whether the agent used what the arm added. `files` and diff --git a/dokimi/dokimi/repos/arms.py b/dokimi/dokimi/repos/arms.py index 4da9fad5e..2da5ead3e 100644 --- a/dokimi/dokimi/repos/arms.py +++ b/dokimi/dokimi/repos/arms.py @@ -27,10 +27,13 @@ import yaml +from dokimi.skills.formats import SPEC_NAME_MAX + from dokimi import process SKILL_KINDS = ("skill", "skill-catalog") SKILLS_DIR = ".claude/skills" +GUIDANCE_SUFFIX = "-guidance" def slug(text: str) -> str: @@ -38,6 +41,13 @@ def slug(text: str) -> str: return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_") or "x" +def guidance_skill_name(repo_id: str) -> str: + """The name of the skill ``guidance:as-skill`` writes for a repository: the Agent Skills name rule (lowercase + letters, digits and single hyphens, at most ``SPEC_NAME_MAX``), so ``numpy.dev`` gives ``numpy-dev-guidance``.""" + words = re.sub(r"[^a-z0-9]+", "-", repo_id.lower()).strip("-")[:SPEC_NAME_MAX - len(GUIDANCE_SUFFIX)] + return f"{words.rstrip('-') or 'repo'}{GUIDANCE_SUFFIX}" + + class ArmError(ValueError): """An arm that does not parse, or whose skills cannot be found.""" @@ -201,8 +211,8 @@ def check_arms(arms: list[Any], content_dir: Path, tasks: list[Any], git_dir: Pa skills = resolve_skills(arm, content_dir) names = {s.name for s in skills} if repo_id is not None and arm.patch is None and arm.base == "guidance:as-skill": - _check_generated_skill(arm, skills, repo_id, tasks, git_dir) - names.add(slug(f"{repo_id}-guidance")) # the skill preparation installs beside the catalog's + _check_generated_skill(arm, skills, repo_id, tasks, git_dir, shipped) + names.add(guidance_skill_name(repo_id)) # the skill preparation installs beside the catalog's drops = arm.patch is None and (arm.base in ("guidance:none", "guidance:as-skill") or arm.rev is not None) rev = (arm.rev_sha or arm.rev) if arm.patch is None else None if skills and git_dir is not None: @@ -212,19 +222,25 @@ def check_arms(arms: list[Any], content_dir: Path, tasks: list[Any], git_dir: Pa _check_expected_skills(arm, tasks, names) -def _check_generated_skill(arm: Any, skills: list[Skill], repo_id: str, tasks: list[Any], git_dir: Path | None) -> None: +def _check_generated_skill(arm: Any, skills: list[Skill], repo_id: str, tasks: list[Any], git_dir: Path | None, + shipped: dict[str, tuple[list[str], list[str]]]) -> None: """Refuse a ``guidance:as-skill`` arm whose generated skill directory is a skill of the arm, or is tracked by the - base of a task: preparation writes that directory first, so it would fail cell by cell, not here.""" - target = f"{SKILLS_DIR}/{slug(f'{repo_id}-guidance')}" + base of a task, or whose base links ``.claude`` or ``.claude/skills`` (preparation installs through both): it + would fail cell by cell, not here. A ``shipped`` cache entry holds the base's files and links.""" + target = f"{SKILLS_DIR}/{guidance_skill_name(repo_id)}" for skill in skills: if skill.target == target: raise ArmError(f"arm {arm.spec!r}: its generated guidance skill {target} is also one of its skills") if git_dir is None: return for task in tasks: - for path in _base_skill_files(git_dir, task.base)[0]: + files, links = _cached_shipped(git_dir, task.base, shipped) + for path in files: if path == target or path.startswith(target + "/"): raise ArmError(f"arm {arm.spec!r}: the base of task {task.id} already has {target}") + for link in links: + raise ArmError(f"arm {arm.spec!r}: the base of task {task.id} tracks {link} as a symlink; " + f"refusing to install {target} through it") def _check_shipped(arm: Any, skills: list[Skill], task: Any, git_dir: Path, @@ -235,13 +251,12 @@ def _check_shipped(arm: Any, skills: list[Skill], task: Any, git_dir: Path, from dokimi.repos.manifest import match_glob drops, rev, guidance_globs = dropping - for commit in (task.base, rev): - if commit is not None and commit not in shipped: - shipped[commit] = _base_skill_files(git_dir, commit) - left = [p for p in shipped[task.base][0] - if not (drops and any(match_glob(g, p) for g in guidance_globs))] - written = [p for p in shipped[rev][0] if any(match_glob(g, p) for g in guidance_globs)] if rev else [] - for link in shipped[task.base][1]: # preparation refuses to install through a link, cell by cell + base_files, base_links = _cached_shipped(git_dir, task.base, shipped) + if rev is not None: + rev_files = _cached_shipped(git_dir, rev, shipped)[0] + left = [p for p in base_files if not (drops and any(match_glob(g, p) for g in guidance_globs))] + written = [p for p in rev_files if any(match_glob(g, p) for g in guidance_globs)] if rev else [] + for link in base_links: # preparation refuses to install through a link, cell by cell raise ArmError(f"arm {arm.spec!r}: the base of task {task.id} tracks {link} as a symlink; " f"refusing to install {skills[0].name} through it") for skill in skills: @@ -250,6 +265,14 @@ def _check_shipped(arm: Any, skills: list[Skill], task: Any, git_dir: Path, raise ArmError(f"arm {arm.spec!r}: {where} already has {skill.target}") +def _cached_shipped(git_dir: Path, commit: str, + shipped: dict[str, tuple[list[str], list[str]]]) -> tuple[list[str], list[str]]: + """``_base_skill_files`` of a commit, one ``git ls-tree`` per commit for the whole check.""" + if commit not in shipped: + shipped[commit] = _base_skill_files(git_dir, commit) + return shipped[commit] + + def _check_expected_skills(arm: Any, tasks: list[Any], names: set[str]) -> None: """A catalog arm has to install the ``expected_skill`` of every task that names one.""" for task in tasks: diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 67de9f2c1..9d7e8be0a 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -8,7 +8,8 @@ above. No other line changes, and :func:`restore` undoes the wrapping exactly. :func:`to_format` refuses a file whose rendering does not restore to it (a body line that looks like one of the tags, a heading the restore could not rebuild), so the arm never measures altered content. -Headings inside code fences are body text. +Headings inside code fences and HTML comment blocks are body text. A leading YAML front matter block +(``---`` to ``---``) stays verbatim above ````, where the harness that reads it still finds it. """ from __future__ import annotations @@ -27,6 +28,8 @@ _BLOCK = re.compile(r" *(?:#{1,6}(?: |$)|>|([-*_])( *\1){2,} *$)") # a heading, quote or rule: a line that starts a block _QUOTE_MARK = re.compile(r" *> ?") # one ``>`` of a block quote, and the space after it _OPEN = re.compile(r'^
$') +_COMMENT = re.compile(r" {0,3}" not in line + out.append(line) + continue fence, items, para, empty = _advance(fence, items, line, para, empty) heading = _HEADING.match(line) if fence is None else None # a line of a fence is body text if heading is None: out.append(line) + if fence is None and _COMMENT.match(line) and "-->" not in line: + comment, para, empty = True, False, False # a comment block is not paragraph text continue level = len(heading.group(1)) while open_levels and open_levels[-1] >= level: diff --git a/dokimi/dokimi/repos/runner.py b/dokimi/dokimi/repos/runner.py index a0a122128..d42774ef5 100644 --- a/dokimi/dokimi/repos/runner.py +++ b/dokimi/dokimi/repos/runner.py @@ -175,6 +175,9 @@ def _add_arm_use(record: dict[str, Any], meta: dict[str, Any], task: Task, skill if meta.get("skills"): # without a trace the usage is unmeasured, not zero record["skills"] = skills if skills is not None else skill_unmeasured( [s["name"] for s in meta["skills"]], task.expected_skill) + if meta.get("guidance_skill"): # apart from the catalog's usage: it is not in the names the catalog's usage counts + record["guidance_skill_loaded"] = (None if events is None else + bool(skill_usage(events, [meta["guidance_skill"]["name"]], None)["skills_used"])) if any(uptake_of): # an arm that added something to use: did the agent use it (#41 D4) record["uptake"] = arm_uptake(events, *uptake_of, WORKSPACE) diff --git a/dokimi/dokimi/repos/workspace.py b/dokimi/dokimi/repos/workspace.py index 920d8361e..7cfa11614 100644 --- a/dokimi/dokimi/repos/workspace.py +++ b/dokimi/dokimi/repos/workspace.py @@ -50,7 +50,7 @@ from dokimi import process -from .arms import SKILLS_DIR, ArmError, Skill, resolve_skills, slug, tree_sha256 +from .arms import SKILLS_DIR, ArmError, Skill, guidance_skill_name, resolve_skills, tree_sha256 from .errors import PrepError from .guidance_format import FORMATS, FormatError, to_format from .harness_profile import HarnessProfile @@ -397,9 +397,7 @@ def prepare_workspace(git_dir: Path, manifest: RepoManifest, task: Task, arm: st arm_files, arm_meta = _apply_arm(ws, env, git_dir, manifest, arm, bridge) # 5 except PrepError as exc: raise PrepError(f"task {task.id}: {exc}") from exc - installed = _install_skills(ws, task, skills) - if "guidance_skill" in arm_meta: # packaged by guidance:as-skill: usage is measured on it like any skill - installed.append(arm_meta.pop("guidance_skill")) + installed = _install_skills(ws, task, skills) # the catalog's skills; a guidance:as-skill skill is in arm_meta _git(ws, env, "add", "-A") # 6 _git(ws, env, "commit", "-q", "--no-gpg-sign", "--no-verify", "--amend", "--allow-empty", "-m", "snapshot") _apply_review_patch(ws, task, patches) # 7 @@ -532,7 +530,7 @@ def _apply_as_skill(ws: Path, manifest: RepoManifest, matched: list[str]) -> tup ``.claude/skills/-guidance/SKILL.md``: each file under a ``## File: `` line, its text verbatim.""" if not matched: raise PrepError("guidance:as-skill: the base has no guidance file to package") - name = slug(f"{manifest.id}-guidance") + name = guidance_skill_name(manifest.id) skill_dir = ws / SKILLS_DIR / name for part in (ws / ".claude", ws / SKILLS_DIR): # a repository link would write to the host if part.is_symlink(): diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 3b6d9400e..de8075c78 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -12,7 +12,7 @@ import yaml from dokimi.repos import RepoSource, load_manifest, open_source -from dokimi.repos.arms import slug, tree_sha256 +from dokimi.repos.arms import guidance_skill_name, tree_sha256 from dokimi.repos.guidance_format import FormatError, restore, to_format from dokimi.repos.workspace import ( GUIDANCE_SKILL_DESCRIPTION, @@ -89,6 +89,9 @@ ("- > quoted\nlazy\n ```\n# Real\n", ["Real"]), # a lazy line continues the quoted paragraph in the item ("> quoted\nlazy\n ```\n# Real\n", []), # the same at top level: the fence opens and holds Real ("- > # H\nlazy\n ```\n# x\n", []), # a quoted heading is no paragraph: the lazy line ends the item + ("- > code\nlazy\n ```\n# Real\n", []), # indented code in the quote is no paragraph: lazy ends the item + ("para\n- > code\nlazy\n ```\n# Real\n", []), # a new item after a paragraph: the same, no lazy continuation + ("- > a\n > more\nlazy\n ```\n# Real\n", ["Real"]), # an open quoted paragraph takes an indented line too ]) def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text, sections): assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections @@ -106,6 +109,10 @@ def test_a_fence_opened_by_a_list_item_holds_body_text_only_inside_the_item(text "# trailing spaces \r\nbody\r\n", "# Title with
and \"quotes\" & \nbody\n", "~~~\n# fenced with tildes\n~~~\n# real\n", + "---\npaths:\n - src/**\n# a yaml comment\n---\n# Rules\n", + "---\n---\n# Rules\n", + "---\nunclosed\n# Rules\n", + "\n# Real\n", ]) def test_xml_round_trips_the_markdown_exactly(text): assert restore(to_format(text, "xml")) == text @@ -123,6 +130,28 @@ def test_xml_wraps_each_heading_in_a_section_closed_before_its_peer(): ) +@pytest.mark.parametrize("text, sections", [ + ("\n# Real\n", ["Real"]), # a comment block spans lines: its heading is body text + ("\n# Real\n", ["Real"]), # closed on its own line: nothing after it is in the comment + (" \n# Real\n", ["Real"]), # up to three spaces of indentation + ("\n", ["Real"]), # a comment inside a paragraph line is no block: the heading is a section +]) +def test_a_heading_inside_an_html_comment_block_is_body_text(text, sections): + assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections + + +def test_a_yaml_front_matter_block_stays_verbatim_above_the_root(): + text = "---\npaths:\n - src/**\n# a yaml comment\n---\n# Rules\n" + assert to_format(text, "xml") == ( + "---\npaths:\n - src/**\n# a yaml comment\n---\n" + "\n" + '
\n\n' + "
\n" + "
" + ) + + def test_a_fenced_hash_line_is_body_text_not_a_section(): rendered = to_format("```\n# not a heading\n```\n", "xml") assert " Date: Mon, 2 Nov 2026 16:20:44 -0800 Subject: [PATCH 18/22] fix(repo): CRLF front matter stays above the root; an HTML comment ends with its list item A front matter block with CRLF delimiters was not recognised, so the root element went above it. A comment block opened inside a list item now ends when a line leaves that item, as a fence does. The guidance-as-skill usage doc now says where the generated skill's usage is recorded. Signed-off-by: Nikolay Petrov --- dokimi/docs/benchmarking-and-comparison.md | 6 ++++-- dokimi/dokimi/repos/guidance_format.py | 20 ++++++++++++++------ dokimi/tests/test_repo_guidance_format.py | 9 +++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/dokimi/docs/benchmarking-and-comparison.md b/dokimi/docs/benchmarking-and-comparison.md index 5b12ffe43..15695d331 100644 --- a/dokimi/docs/benchmarking-and-comparison.md +++ b/dokimi/docs/benchmarking-and-comparison.md @@ -327,8 +327,10 @@ the cells the way the skill arms are compared above. symlink to any other file fails the cell. The frontmatter description is one fixed sentence for every repository, so the skill is selected on its description alone. Nested guidance files lose the directory - they applied to; the skill records its sha256 and appears in `skills` like any - installed skill, so `repo report` counts whether the agent loaded it. + they applied to; the skill's name, path and sha256 are recorded in + `meta.json` under `guidance_skill`. Its usage is recorded separately in + `result.json` as `guidance_skill_loaded` (`null` if the trace is unreadable), + and is not currently aggregated by `repo report`. Reading the two together: `guidance:format=xml` against `guidance:raw` isolates the markup, and `guidance:as-skill` against `guidance:raw` isolates the packaging diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index 9d7e8be0a..c1118ea71 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -166,9 +166,9 @@ def _advance(fence: tuple[str, int] | None, items: tuple[int, ...], line: str, p def _front_matter_end(lines: list[str]) -> int: """The index of the line that closes a leading YAML front matter block (``---`` to ``---``), or -1 if none.""" - if lines and lines[0] == _FRONT: + if lines and lines[0].rstrip("\r") == _FRONT: # a CRLF file's delimiter keeps its CR in the lines, not in the match for index in range(1, len(lines)): - if lines[index] == _FRONT: + if lines[index].rstrip("\r") == _FRONT: return index return -1 @@ -180,18 +180,26 @@ def _render_xml(text: str) -> str: fence: tuple[str, int] | None = None items: tuple[int, ...] = () para = empty = comment = False + comment_column = 0 # the content column of the item a comment block is in: a line indented less ends both open_levels: list[int] = [] for line in lines[end + 1:]: - if comment: # inside an HTML comment block: body text up to the line that closes it - comment = "-->" not in line - out.append(line) - continue + if comment: # inside an HTML comment block: body text up to the line that closes it or leaves its item + expanded = line.expandtabs(4) + if line.strip() and len(expanded) - len(expanded.lstrip(" ")) < comment_column: + comment = False # the item ends with the block, so the line is read as any other + else: + comment = "-->" not in line + out.append(line) + continue fence, items, para, empty = _advance(fence, items, line, para, empty) heading = _HEADING.match(line) if fence is None else None # a line of a fence is body text if heading is None: out.append(line) if fence is None and _COMMENT.match(line) and "-->" not in line: + expanded = line.expandtabs(4) + indent = len(expanded) - len(expanded.lstrip(" ")) comment, para, empty = True, False, False # a comment block is not paragraph text + comment_column = items[-1] if items and indent >= items[-1] else 0 # a lazy line is in no item continue level = len(heading.group(1)) while open_levels and open_levels[-1] >= level: diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index de8075c78..5f88373b6 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -136,6 +136,8 @@ def test_xml_wraps_each_heading_in_a_section_closed_before_its_peer(): (" \n# Real\n", ["Real"]), # up to three spaces of indentation ("\n", ["Real"]), # a comment inside a paragraph line is no block: the heading is a section + ("- item\n \n", ["Real"]), # the item ends at the heading, and its comment block with it + ("- item\n\n", []), # a lazy comment line starts a block outside the item: the comment holds on ]) def test_a_heading_inside_an_html_comment_block_is_body_text(text, sections): assert re.findall(r'title="([^"]*)"', to_format(text, "xml")) == sections @@ -152,6 +154,13 @@ def test_a_yaml_front_matter_block_stays_verbatim_above_the_root(): ) +def test_a_crlf_front_matter_block_stays_verbatim_above_the_root(): + text = "---\r\npaths:\r\n - src/**\r\n---\r\n# Rules\r\n" + rendered = to_format(text, "xml") # the delimiters keep their CR; the match ignores it + assert rendered.startswith("---\r\npaths:\r\n - src/**\r\n---\r\n\n") + assert restore(rendered) == text + + def test_a_fenced_hash_line_is_body_text_not_a_section(): rendered = to_format("```\n# not a heading\n```\n", "xml") assert " Date: Mon, 2 Nov 2026 22:22:06 -0800 Subject: [PATCH 19/22] fix(repo): a catalog arm refuses an expected_skill naming the generated guidance skill A guidance:as-skill arm on a skill-catalog accepted an expected_skill that named the generated -guidance skill. Its usage is recorded apart from the catalog's, so the catalog usage read the expected skill as never invoked. check_arms now refuses it before any cell; a catalog skill still passes. Signed-off-by: Nikolay Petrov --- ...0261031_120000_napetrov_guidance-format-arms.md | 2 +- dokimi/docs/repo-evaluation-contract.md | 2 +- dokimi/dokimi/repos/arms.py | 14 ++++++++++---- dokimi/tests/test_repo_skill_arms.py | 9 ++++++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md b/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md index 082c7642c..1767c07e8 100644 --- a/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md +++ b/dokimi/changelog.d/20261031_120000_napetrov_guidance-format-arms.md @@ -1,3 +1,3 @@ ### Added -- **`repo run` arms `guidance:format=xml` and `guidance:as-skill`** — the guidance files of the base, rendered as XML tags (each Markdown heading becomes a `
` element, checked to restore to the original file exactly), or moved into one skill directory (`.claude/skills/-guidance/SKILL.md`, the repo id made into an Agent Skills name). Both compare the same guidance content in another shape under the same model; `guidance:format=md` is refused as a duplicate of `guidance:raw`, and POML is not offered yet. +- **`repo run` arms `guidance:format=xml` and `guidance:as-skill`** — the guidance files of the base, rendered as XML tags (each Markdown heading becomes a `
` element, checked to restore to the original file exactly), or moved into one skill directory (`.claude/skills/-guidance/SKILL.md`, the repo id made into an Agent Skills name). Both compare the same guidance content in another shape under the same model; `guidance:format=md` is refused as a duplicate of `guidance:raw`, and POML is not offered yet. A `guidance:as-skill` catalog arm refuses an `expected_skill` that names the generated skill, before any cell. diff --git a/dokimi/docs/repo-evaluation-contract.md b/dokimi/docs/repo-evaluation-contract.md index e489b0867..7af92fed1 100644 --- a/dokimi/docs/repo-evaluation-contract.md +++ b/dokimi/docs/repo-evaluation-contract.md @@ -306,7 +306,7 @@ belongs to the repository's grader and is passed through unread, except `grader` | `timeout_s`, `max_turns`, `budget_usd` | no | framework | per-task caps, overriding run defaults; each a finite number > 0 | | `host` | no | framework | `{"min_cpus": , "cpu_vendors": [], "cpu_flags": []}`, every key optional: what the hidden test needs to show the bug (a thread race that needs 3+ CPUs, an ISA path). Vendor and flag names are `/proc/cpuinfo`'s (`GenuineIntel`, `avx512f`); both match in any case, and a vendor other than `GenuineIntel`, `AuthenticAMD`, `HygonGenuine`, `CentaurHauls`, `Shanghai` is refused (`repo validate` too). A probe that read neither vendor nor flags from `/proc/cpuinfo` is a probe failure, not a miss They are checked against a probe container started like the grading one: CPUs are its affinity mask capped by its cgroup CPU quota (`docker --cpus`). On a host that misses them the task is unsupported (§4.3), and the probed facts go to `meta.json` `host` of a cell that runs, or to its `unsupported.json` `host` when it does not | | `egress` | no | framework | extra hosts (DNS names, optional `:port`; `*` stands for one whole label and the last two labels are literal, so `*.pythonhosted.org` passes and `*` or `*.org` does not) this task's agent and entry points reach through the proxy (§2), for example `["pypi.org", "files.pythonhosted.org"]` | -| `expected_skill` | no | framework | the name (directory name) of the skill the agent should use; a `skill-catalog:` arm must install it, or the arm is refused before anything runs. `result.json` `skills` records whether the agent used it and whether it used it first | +| `expected_skill` | no | framework | the name (directory name) of the skill the agent should use; a `skill-catalog:` arm must install it, or the arm is refused before anything runs. On a `guidance:as-skill` catalog arm the generated `-guidance` skill is refused too (its usage is `guidance_skill_loaded`). `result.json` `skills` records whether the agent used it and whether it used it first | | anything else | — | repository | for example `hidden_files`, `targets`, `regression_targets`, `manifest` | `setup_patch` (a single string) is accepted as a synonym for a one-element diff --git a/dokimi/dokimi/repos/arms.py b/dokimi/dokimi/repos/arms.py index 2da5ead3e..4405f5f3a 100644 --- a/dokimi/dokimi/repos/arms.py +++ b/dokimi/dokimi/repos/arms.py @@ -210,16 +210,17 @@ def check_arms(arms: list[Any], content_dir: Path, tasks: list[Any], git_dir: Pa raise ArmError(f"arms {cells[cell]!r} and {arm.spec!r} would share one result directory ({cell})") skills = resolve_skills(arm, content_dir) names = {s.name for s in skills} + generated = None # the skill preparation installs beside the catalog's; its usage is scored apart if repo_id is not None and arm.patch is None and arm.base == "guidance:as-skill": _check_generated_skill(arm, skills, repo_id, tasks, git_dir, shipped) - names.add(guidance_skill_name(repo_id)) # the skill preparation installs beside the catalog's + generated = guidance_skill_name(repo_id) drops = arm.patch is None and (arm.base in ("guidance:none", "guidance:as-skill") or arm.rev is not None) rev = (arm.rev_sha or arm.rev) if arm.patch is None else None if skills and git_dir is not None: for task in tasks: _check_shipped(arm, skills, task, git_dir, shipped, (drops, rev, guidance_globs)) if any(kind == "skill-catalog" for kind, _ in arm.skills): - _check_expected_skills(arm, tasks, names) + _check_expected_skills(arm, tasks, names, generated) def _check_generated_skill(arm: Any, skills: list[Skill], repo_id: str, tasks: list[Any], git_dir: Path | None, @@ -273,10 +274,15 @@ def _cached_shipped(git_dir: Path, commit: str, return shipped[commit] -def _check_expected_skills(arm: Any, tasks: list[Any], names: set[str]) -> None: - """A catalog arm has to install the ``expected_skill`` of every task that names one.""" +def _check_expected_skills(arm: Any, tasks: list[Any], names: set[str], generated: str | None = None) -> None: + """A catalog arm has to install the ``expected_skill`` of every task that names one. The generated guidance + skill of a ``guidance:as-skill`` arm (``generated``) is refused as one: its usage is recorded apart from the + catalog's (``guidance_skill_loaded``), so the catalog's usage would read it as never invoked.""" for task in tasks: expected = task.raw.get("expected_skill") + if expected and expected == generated: + raise ArmError(f"arm {arm.spec!r}: task {task.id}'s expected_skill {expected!r} is the generated guidance " + f"skill of the arm; name a skill of the catalog instead") if expected and expected not in names: raise ArmError(f"arm {arm.spec!r}: task {task.id}'s expected_skill {expected!r} is not installed " f"(installed: {', '.join(sorted(names))})") diff --git a/dokimi/tests/test_repo_skill_arms.py b/dokimi/tests/test_repo_skill_arms.py index f5101b1cb..b12594e21 100644 --- a/dokimi/tests/test_repo_skill_arms.py +++ b/dokimi/tests/test_repo_skill_arms.py @@ -837,9 +837,16 @@ def test_the_as_skill_arm_removes_the_base_skill_it_packages_so_it_is_not_refuse check_arms([parse_arm("guidance:raw+skill:skills/lint")], content, tasks, root / ".git", globs) -def test_a_catalog_arm_with_as_skill_accepts_an_expected_skill_naming_the_generated_skill(tmp_path): +def test_a_catalog_arm_with_as_skill_refuses_an_expected_skill_naming_the_generated_skill(tmp_path): _skill(tmp_path, "skills/lint", "lint") # the catalog holds lint only; preparation installs toy-guidance beside it task = Task("t", {"expected_skill": "toy-guidance"}, tmp_path) + with pytest.raises(ArmError, match="expected_skill 'toy-guidance' is the generated guidance skill"): + check_arms([parse_arm("guidance:as-skill+skill-catalog:skills")], tmp_path, [task], None, (), "toy") + + +def test_a_catalog_arm_with_as_skill_still_accepts_an_expected_skill_from_the_catalog(tmp_path): + _skill(tmp_path, "skills/lint", "lint") + task = Task("t", {"expected_skill": "lint"}, tmp_path) check_arms([parse_arm("guidance:as-skill+skill-catalog:skills")], tmp_path, [task], None, (), "toy") From d678ed4a9936ebc4d5376b47e6ca8a91fa69dfcc Mon Sep 17 00:00:00 2001 From: Vladislav Perevezentsev Date: Tue, 3 Nov 2026 04:20:20 -0700 Subject: [PATCH 20/22] fix(repo): a column-0 HTML comment after a list item ends the item Signed-off-by: Vladislav Perevezentsev --- dokimi/dokimi/repos/guidance_format.py | 5 +++-- dokimi/tests/test_repo_guidance_format.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/repos/guidance_format.py b/dokimi/dokimi/repos/guidance_format.py index c1118ea71..6f2570e2e 100644 --- a/dokimi/dokimi/repos/guidance_format.py +++ b/dokimi/dokimi/repos/guidance_format.py @@ -94,13 +94,14 @@ def _items_after(items: tuple[int, ...], line: str, para: bool = False) -> tuple """The list items open after a line read outside a fence, outermost first. A blank line keeps them. A non-blank line ends the items whose content column it is indented less than, and a list marker it starts opens one item per marker at its content column. A line that continues paragraph text (``para``) ends nothing, unless it starts a - block (a heading, quote or rule, or a fence) or a list item.""" + block (a heading, quote or rule, a fence or an HTML comment) or a list item.""" if not line.strip(): return items expanded = line.expandtabs(4) indent = len(expanded) - len(expanded.lstrip(" ")) markers = _markers(items, line, para) - if not markers and para and not _BLOCK.match(expanded) and not _OPENER.match(expanded): + if (not markers and para and not _BLOCK.match(expanded) and not _OPENER.match(expanded) + and not _COMMENT.match(expanded)): return items return tuple(column for column in items if column <= indent) + markers diff --git a/dokimi/tests/test_repo_guidance_format.py b/dokimi/tests/test_repo_guidance_format.py index 5f88373b6..180111e60 100644 --- a/dokimi/tests/test_repo_guidance_format.py +++ b/dokimi/tests/test_repo_guidance_format.py @@ -138,6 +138,7 @@ def test_xml_wraps_each_heading_in_a_section_closed_before_its_peer(): ("text \n", ["Real"]), # a comment inside a paragraph line is no block: the heading is a section ("- item\n \n", ["Real"]), # the item ends at the heading, and its comment block with it ("- item\n\n", []), # a lazy comment line starts a block outside the item: the comment holds on + ("- a\n\n