From 099c2b80bdded7180a7a958738a2f9395574f1ad Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sat, 31 Oct 2026 22:26:54 -0700 Subject: [PATCH 1/6] feat(subjects): distribution_digest, the digest a well-known skill index carries subject_scorecard.v2 gains subject.distribution_digest: sha256 of the SKILL.md bytes for a file ref or a directory holding only SKILL.md, the value a .well-known/agent-skills index.json entry of type skill-md carries (agentskills PR #254). An archive's bytes depend on how it was packed, so a skill with resources gets null, as does a non-local ref. ref_digest is unchanged. Signed-off-by: Nikolay Petrov --- ...031_120000_napetrov_distribution-digest.md | 8 +++ .../dokimi/schemas/subject_scorecard.v2.json | 6 +++ dokimi/dokimi/subjects/scorecard.py | 28 +++++++++++ dokimi/tests/test_subjects_digest.py | 50 +++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md create mode 100644 dokimi/tests/test_subjects_digest.py diff --git a/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md b/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md new file mode 100644 index 000000000..e2538d4fe --- /dev/null +++ b/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md @@ -0,0 +1,8 @@ + + +### Added + +- **`subject.distribution_digest` in the subject scorecard** — the `sha256:` digest a `.well-known/agent-skills/index.json` entry (agentskills PR #254) would carry for the subject: the bytes of `SKILL.md` for a file ref, or for a directory whose only file is `SKILL.md`. A skill with resources is served as an archive, whose bytes a checkout cannot predict, so it gets `null`, and so does a ref that is not a local path. A client can match a published result to the skill it installed by this field. `ref_digest` is unchanged: a content digest of the tree, which no index carries. diff --git a/dokimi/dokimi/schemas/subject_scorecard.v2.json b/dokimi/dokimi/schemas/subject_scorecard.v2.json index 3c3d85899..af9b200be 100644 --- a/dokimi/dokimi/schemas/subject_scorecard.v2.json +++ b/dokimi/dokimi/schemas/subject_scorecard.v2.json @@ -45,6 +45,12 @@ "null" ] }, + "distribution_digest": { + "type": [ + "string", + "null" + ] + }, "members": { "type": "array" } diff --git a/dokimi/dokimi/subjects/scorecard.py b/dokimi/dokimi/subjects/scorecard.py index 4e0493541..ebb7abda5 100644 --- a/dokimi/dokimi/subjects/scorecard.py +++ b/dokimi/dokimi/subjects/scorecard.py @@ -76,11 +76,13 @@ def build_subject_scorecard( "kind": descriptor.subject.kind, "ref": descriptor.subject.ref, "ref_digest": _digest_ref(descriptor.subject.ref, base=Path.cwd()), + "distribution_digest": distribution_digest(descriptor.subject.ref, base=Path.cwd()), "members": [ { "kind": member.kind, "ref": member.ref, "ref_digest": _digest_ref(member.ref, base=Path.cwd()), + "distribution_digest": distribution_digest(member.ref, base=Path.cwd()), } for member in descriptor.subject.members ], @@ -138,6 +140,32 @@ def _digest_values(values: tuple[str, ...]) -> str: return "sha256:" + h.hexdigest() +def distribution_digest(ref: str | None, *, base: Path) -> str | None: + """The digest a ``.well-known/agent-skills`` index would publish for *ref*, or None. + + That index (agentskills PR #254) hashes the bytes it serves: one ``SKILL.md`` for a + ``skill-md`` entry, the archive for an ``archive`` entry. Only the first can be derived + from a checkout: a ``SKILL.md`` file ref is hashed as is, and a directory whose only file is + ``SKILL.md`` is hashed as that file. A directory with resources is served as an archive, + whose bytes depend on how it was packed, so it gets None rather than a digest no index + carries. So does a ref that is not a local path. ``ref_digest`` stays a content digest + of the tree. + """ + if not ref: + return None + path = Path(ref.removeprefix("local:")) + if not path.is_absolute(): + path = base / path + if path.is_dir(): + # Any other entry makes it an archive; one stray file is enough, so stop at the second. + if [p.name for _, p in zip(range(2), path.iterdir(), strict=False)] != ["SKILL.md"]: + return None + path = path / "SKILL.md" + if path.name != "SKILL.md" or not path.is_file(): + return None + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + def _digest_ref(ref: str | None, *, base: Path) -> str | None: if not ref: return None diff --git a/dokimi/tests/test_subjects_digest.py b/dokimi/tests/test_subjects_digest.py new file mode 100644 index 000000000..e29923b9c --- /dev/null +++ b/dokimi/tests/test_subjects_digest.py @@ -0,0 +1,50 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""subject.distribution_digest: the digest a .well-known/agent-skills index carries.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from dokimi.subjects.scorecard import distribution_digest + + +def test_distribution_digest_matches_a_well_known_skill_md_entry(tmp_path: Path): + # agentskills PR #254: a skill-md index entry carries sha256 of the SKILL.md bytes it serves. + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_bytes(b"---\nname: s\n---\nbody\n") + expected = "sha256:" + hashlib.sha256(b"---\nname: s\n---\nbody\n").hexdigest() + + assert distribution_digest("skill", base=tmp_path) == expected + assert distribution_digest("local:skill/SKILL.md", base=tmp_path) == expected + + # With resources the skill is served as an archive, whose bytes a checkout cannot predict. + (skill / "ref.md").write_text("more", encoding="utf-8") + assert distribution_digest("skill", base=tmp_path) is None + assert distribution_digest("skill/SKILL.md", base=tmp_path) == expected + # A ref that is not a local path has no served bytes to hash. + assert distribution_digest("context7:/org/lib", base=tmp_path) is None + assert distribution_digest(None, base=tmp_path) is None + # Only SKILL.md is served as a skill-md entry; another file has no index digest. + (tmp_path / "notes.md").write_text("x", encoding="utf-8") + assert distribution_digest("notes.md", base=tmp_path) is None + + +def test_the_scorecard_carries_the_distribution_digest(tmp_path: Path, monkeypatch): + from dokimi.artifacts import validate_artifact + from dokimi.subjects import build_subject_scorecard, load_subject + + (tmp_path / "skill").mkdir() + (tmp_path / "skill" / "SKILL.md").write_bytes(b"body") + (tmp_path / "subject.toml").write_text( + '[subject]\nid = "s"\nkind = "skill"\nref = "skill"\n\n' + '[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + + scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path) + + assert scorecard["subject"]["distribution_digest"] == "sha256:" + hashlib.sha256(b"body").hexdigest() + validate_artifact("subject_scorecard", scorecard) From de58607407be641b5d45690478d21b02074a6c77 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 14:22:07 +0000 Subject: [PATCH 2/6] Give a ref with a non-local scheme no distribution digest Signed-off-by: Anton Volkov --- dokimi/dokimi/subjects/scorecard.py | 3 +++ dokimi/tests/test_subjects_digest.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/dokimi/dokimi/subjects/scorecard.py b/dokimi/dokimi/subjects/scorecard.py index ebb7abda5..c0f42cbe6 100644 --- a/dokimi/dokimi/subjects/scorecard.py +++ b/dokimi/dokimi/subjects/scorecard.py @@ -6,6 +6,7 @@ from __future__ import annotations import hashlib +import re from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -153,6 +154,8 @@ def distribution_digest(ref: str | None, *, base: Path) -> str | None: """ if not ref: return None + if re.match(r"[A-Za-z][A-Za-z0-9+.-]+:", ref) and not ref.startswith("local:"): + return None path = Path(ref.removeprefix("local:")) if not path.is_absolute(): path = base / path diff --git a/dokimi/tests/test_subjects_digest.py b/dokimi/tests/test_subjects_digest.py index e29923b9c..55c74d3a0 100644 --- a/dokimi/tests/test_subjects_digest.py +++ b/dokimi/tests/test_subjects_digest.py @@ -33,6 +33,14 @@ def test_distribution_digest_matches_a_well_known_skill_md_entry(tmp_path: Path) assert distribution_digest("notes.md", base=tmp_path) is None +def test_a_ref_with_a_scheme_gets_no_digest_even_when_a_directory_collides(tmp_path: Path): + colliding = tmp_path / "context7:" / "org" / "lib" + colliding.mkdir(parents=True) + (colliding / "SKILL.md").write_bytes(b"body") + + assert distribution_digest("context7:/org/lib", base=tmp_path) is None + + def test_the_scorecard_carries_the_distribution_digest(tmp_path: Path, monkeypatch): from dokimi.artifacts import validate_artifact from dokimi.subjects import build_subject_scorecard, load_subject From 1dbde6a3cf9b1a5d2cce5da3145477669f217788 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 14:22:24 +0000 Subject: [PATCH 3/6] Test the distribution digest of each bundle member Signed-off-by: Anton Volkov --- dokimi/tests/test_subjects_digest.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/dokimi/tests/test_subjects_digest.py b/dokimi/tests/test_subjects_digest.py index 55c74d3a0..c0e544b96 100644 --- a/dokimi/tests/test_subjects_digest.py +++ b/dokimi/tests/test_subjects_digest.py @@ -56,3 +56,20 @@ def test_the_scorecard_carries_the_distribution_digest(tmp_path: Path, monkeypat assert scorecard["subject"]["distribution_digest"] == "sha256:" + hashlib.sha256(b"body").hexdigest() validate_artifact("subject_scorecard", scorecard) + + +def test_the_scorecard_carries_a_distribution_digest_per_member(tmp_path: Path, monkeypatch): + from dokimi.subjects import build_subject_scorecard, load_subject + + (tmp_path / "SKILL.md").write_bytes(b"member body") + (tmp_path / "subject.toml").write_text( + '[subject]\nid = "b"\nkind = "bundle"\n\n' + '[[subject.members]]\nkind = "skill"\nref = "SKILL.md"\n\n' + '[[subject.members]]\nkind = "skill"\nref = "context7:/org/lib"\n\n' + '[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + + scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path) + + digests = [member["distribution_digest"] for member in scorecard["subject"]["members"]] + assert digests == ["sha256:" + hashlib.sha256(b"member body").hexdigest(), None] From a5c550022dfce59e6cd356e15c10a10a8e5a9fdf Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 09:46:11 -0800 Subject: [PATCH 4/6] fix(subjects): a SKILL.md file ref with resources gets no digest either A SKILL.md named by its file ref was hashed even when its directory held resources, while the directory ref got None. The index serves such a skill as an archive under either spelling, so both now get None. The member test covers a standalone member, a bundled one with resources, and a non-local ref. Signed-off-by: Nikolay Petrov --- ...031_120000_napetrov_distribution-digest.md | 2 +- dokimi/dokimi/subjects/scorecard.py | 25 +++++++++++-------- dokimi/tests/test_subjects_digest.py | 14 ++++++++--- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md b/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md index e2538d4fe..9e2af52fb 100644 --- a/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md +++ b/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md @@ -5,4 +5,4 @@ SPDX-License-Identifier: Apache-2.0 ### Added -- **`subject.distribution_digest` in the subject scorecard** — the `sha256:` digest a `.well-known/agent-skills/index.json` entry (agentskills PR #254) would carry for the subject: the bytes of `SKILL.md` for a file ref, or for a directory whose only file is `SKILL.md`. A skill with resources is served as an archive, whose bytes a checkout cannot predict, so it gets `null`, and so does a ref that is not a local path. A client can match a published result to the skill it installed by this field. `ref_digest` is unchanged: a content digest of the tree, which no index carries. +- **`subject.distribution_digest` in the subject scorecard** — the `sha256:` digest a `.well-known/agent-skills/index.json` entry (agentskills PR #254) would carry for the subject: the bytes of `SKILL.md` when its directory holds nothing else (a directory ref or a `SKILL.md` file ref alike). A skill with resources is served as an archive, whose bytes a checkout cannot predict, so it gets `null` under either ref, and so does a ref that is not a local path. A client can match a published result to the skill it installed by this field. `ref_digest` is unchanged: a content digest of the tree, which no index carries. diff --git a/dokimi/dokimi/subjects/scorecard.py b/dokimi/dokimi/subjects/scorecard.py index c0f42cbe6..b2772bf19 100644 --- a/dokimi/dokimi/subjects/scorecard.py +++ b/dokimi/dokimi/subjects/scorecard.py @@ -146,11 +146,11 @@ def distribution_digest(ref: str | None, *, base: Path) -> str | None: That index (agentskills PR #254) hashes the bytes it serves: one ``SKILL.md`` for a ``skill-md`` entry, the archive for an ``archive`` entry. Only the first can be derived - from a checkout: a ``SKILL.md`` file ref is hashed as is, and a directory whose only file is - ``SKILL.md`` is hashed as that file. A directory with resources is served as an archive, - whose bytes depend on how it was packed, so it gets None rather than a digest no index - carries. So does a ref that is not a local path. ``ref_digest`` stays a content digest - of the tree. + from a checkout. A ``SKILL.md`` is hashed as is when its directory holds nothing else, + whether the ref names the file or the directory. A skill whose directory holds resources + is served as an archive, whose bytes depend on how it was packed, so it gets None rather + than a digest no index carries, under either spelling of its ref. So does a ref that is + not a local path. ``ref_digest`` stays a content digest of the tree. """ if not ref: return None @@ -160,13 +160,16 @@ def distribution_digest(ref: str | None, *, base: Path) -> str | None: if not path.is_absolute(): path = base / path if path.is_dir(): - # Any other entry makes it an archive; one stray file is enough, so stop at the second. - if [p.name for _, p in zip(range(2), path.iterdir(), strict=False)] != ["SKILL.md"]: - return None - path = path / "SKILL.md" - if path.name != "SKILL.md" or not path.is_file(): + skill_dir = path + elif path.name == "SKILL.md" and path.is_file(): + skill_dir = path.parent + else: + return None + skill = skill_dir / "SKILL.md" + # Any other entry makes it an archive; one stray file is enough, so stop at the second. + if [p.name for _, p in zip(range(2), skill_dir.iterdir(), strict=False)] != ["SKILL.md"] or not skill.is_file(): return None - return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + return "sha256:" + hashlib.sha256(skill.read_bytes()).hexdigest() def _digest_ref(ref: str | None, *, base: Path) -> str | None: diff --git a/dokimi/tests/test_subjects_digest.py b/dokimi/tests/test_subjects_digest.py index c0e544b96..48d3ceeb8 100644 --- a/dokimi/tests/test_subjects_digest.py +++ b/dokimi/tests/test_subjects_digest.py @@ -24,7 +24,7 @@ def test_distribution_digest_matches_a_well_known_skill_md_entry(tmp_path: Path) # With resources the skill is served as an archive, whose bytes a checkout cannot predict. (skill / "ref.md").write_text("more", encoding="utf-8") assert distribution_digest("skill", base=tmp_path) is None - assert distribution_digest("skill/SKILL.md", base=tmp_path) == expected + assert distribution_digest("skill/SKILL.md", base=tmp_path) is None # the same skill, by its file ref # A ref that is not a local path has no served bytes to hash. assert distribution_digest("context7:/org/lib", base=tmp_path) is None assert distribution_digest(None, base=tmp_path) is None @@ -61,15 +61,21 @@ def test_the_scorecard_carries_the_distribution_digest(tmp_path: Path, monkeypat def test_the_scorecard_carries_a_distribution_digest_per_member(tmp_path: Path, monkeypatch): from dokimi.subjects import build_subject_scorecard, load_subject - (tmp_path / "SKILL.md").write_bytes(b"member body") + (tmp_path / "standalone").mkdir() + (tmp_path / "standalone" / "SKILL.md").write_bytes(b"member body") + (tmp_path / "bundled").mkdir() + (tmp_path / "bundled" / "SKILL.md").write_bytes(b"bundled body") + (tmp_path / "bundled" / "ref.md").write_text("resource", encoding="utf-8") (tmp_path / "subject.toml").write_text( '[subject]\nid = "b"\nkind = "bundle"\n\n' - '[[subject.members]]\nkind = "skill"\nref = "SKILL.md"\n\n' + '[[subject.members]]\nkind = "skill"\nref = "standalone"\n\n' + '[[subject.members]]\nkind = "skill"\nref = "bundled/SKILL.md"\n\n' '[[subject.members]]\nkind = "skill"\nref = "context7:/org/lib"\n\n' '[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8") monkeypatch.chdir(tmp_path) scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path) + # The standalone member has a digest; the bundled one has resources, so neither its file nor its directory does. digests = [member["distribution_digest"] for member in scorecard["subject"]["members"]] - assert digests == ["sha256:" + hashlib.sha256(b"member body").hexdigest(), None] + assert digests == ["sha256:" + hashlib.sha256(b"member body").hexdigest(), None, None] From 08cd0113e859fcb3d857f54dc92bc44e68ed4629 Mon Sep 17 00:00:00 2001 From: Nikolay Petrov Date: Sun, 1 Nov 2026 11:03:58 -0800 Subject: [PATCH 5/6] test(subjects): skip the colon-named collision fixture on Windows A colon is not allowed in a Windows directory name, so the fixture raised before the scheme guard ran and the Windows smoke path failed. The collision case stays on POSIX; the rest of the file runs everywhere. Signed-off-by: Nikolay Petrov --- dokimi/tests/test_subjects_digest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dokimi/tests/test_subjects_digest.py b/dokimi/tests/test_subjects_digest.py index 48d3ceeb8..a7ff3b1c8 100644 --- a/dokimi/tests/test_subjects_digest.py +++ b/dokimi/tests/test_subjects_digest.py @@ -6,8 +6,11 @@ from __future__ import annotations import hashlib +import sys from pathlib import Path +import pytest + from dokimi.subjects.scorecard import distribution_digest @@ -33,6 +36,7 @@ def test_distribution_digest_matches_a_well_known_skill_md_entry(tmp_path: Path) assert distribution_digest("notes.md", base=tmp_path) is None +@pytest.mark.skipif(sys.platform == "win32", reason="a colon is invalid in a Windows directory name") def test_a_ref_with_a_scheme_gets_no_digest_even_when_a_directory_collides(tmp_path: Path): colliding = tmp_path / "context7:" / "org" / "lib" colliding.mkdir(parents=True) From 3f98c22af54a9f6365ec84da887295da343486ae Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sun, 1 Nov 2026 21:23:09 +0000 Subject: [PATCH 6/6] Use CRLF bytes in the digest fixture to pin raw-byte hashing Signed-off-by: Anton Volkov --- dokimi/tests/test_subjects_digest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dokimi/tests/test_subjects_digest.py b/dokimi/tests/test_subjects_digest.py index a7ff3b1c8..2892617e0 100644 --- a/dokimi/tests/test_subjects_digest.py +++ b/dokimi/tests/test_subjects_digest.py @@ -18,8 +18,8 @@ def test_distribution_digest_matches_a_well_known_skill_md_entry(tmp_path: Path) # agentskills PR #254: a skill-md index entry carries sha256 of the SKILL.md bytes it serves. skill = tmp_path / "skill" skill.mkdir() - (skill / "SKILL.md").write_bytes(b"---\nname: s\n---\nbody\n") - expected = "sha256:" + hashlib.sha256(b"---\nname: s\n---\nbody\n").hexdigest() + (skill / "SKILL.md").write_bytes(b"---\r\nname: s\r\n---\r\nbody\r\n") + expected = "sha256:" + hashlib.sha256(b"---\r\nname: s\r\n---\r\nbody\r\n").hexdigest() assert distribution_digest("skill", base=tmp_path) == expected assert distribution_digest("local:skill/SKILL.md", base=tmp_path) == expected