feat(card): a folder shows every bundle under it, as the server does

`okf card <folder>` prints `okf_list` and `okf_describe` with no bundle
named, joined by `mcp_server.overview`, and computes nothing of its own:
one source, two doors. Chosen over a separate `--root` flag because the
skill's first step must be the same command whether it was pointed at a
bundle or at a folder of them; the rule that decides is discovery's own
(a directory carrying an `index.md` is a bundle). A bundle path prints its
card as before.

v1.1 order F, part F1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-21 10:28:32 +02:00
commit df83c65e32
5 changed files with 212 additions and 4 deletions

View file

@ -1523,6 +1523,15 @@ R761 **8** (S1-S6 + KP + KN), vegnormal **32** questions / **43**
rather than answering from stale numbers — safe to keep, not enough to keep rather than answering from stale numbers — safe to keep, not enough to keep
default. Both forms now carry `## Working method` and `## Answer form`, default. Both forms now carry `## Working method` and `## Answer form`,
required by `okf check` and by the contract's SS 2.5/2.6. required by `okf check` and by the contract's SS 2.5/2.6.
- **`okf card <folder>` SEES EVERY BUNDLE UNDER A FOLDER (v1.1 F1,
2026-09-21).** Until then only the server (`okf mcp --root`) could; the
generic skill read the one bundle it was pointed at. A path that carries no
`index.md` is a folder (`mcp_server.is_bundle`, discovery's own rule), and the
command prints `mcp_server.overview`: `okf_list` and `okf_describe` with no
bundle named, joined, computing nothing of its own -- one source, two doors,
held by a test comparing the printed bytes against the two functions. A
bundle path prints its card exactly as before. Tests over two invented
bundles: `tests/test_folder_of_bundles.py`.
- **`okf card <bundle>` and the generic skill are the one-to-many form.** The card is one bundle's identity, concept count, - **`okf card <bundle>` and the generic skill are the one-to-many form.** The card is one bundle's identity, concept count,
conditional-field counts and whole-bundle cost as JSON, **DERIVED on every run conditional-field counts and whole-bundle cost as JSON, **DERIVED on every run
and never written into the bundle** -- storing it would move the bytes of all and never written into the bundle** -- storing it would move the bytes of all

View file

@ -1397,6 +1397,21 @@ sub-questions in. The lines are capped at 48 000 bytes together
(`lines_truncated` counts what a larger bundle leaves out), and a line lists at (`lines_truncated` counts what a larger bundle leaves out), and a line lists at
most 24 titles. The map replaced the card's flat `source_files` list. most 24 titles. The map replaced the card's flat `source_files` list.
**Point it at a folder and it sees every bundle under it** (since v1.1 F):
```sh
okf card ~/okf # every bundle under the folder, each with its card
```
It prints what the server's `okf_list` and `okf_describe` give with no bundle
named -- the listing (id, ref, concept count, directory), the directories that
look like a bundle and cannot be read as one, and every bundle's card -- and it
computes nothing of its own: the command calls the server's two functions. A
bundle added or rebuilt under the folder is in the next run's answer with
nothing regenerated. Pointed at one bundle, it prints that bundle's card as
before; the command decides which it was given by the same rule discovery uses
(a directory carrying an `index.md` is a bundle).
`okf skill <bundle> --for-bundle` still writes the per-bundle form, with the `okf skill <bundle> --for-bundle` still writes the per-bundle form, with the
identity and the numbers measured into the text — which is exactly what makes identity and the numbers measured into the text — which is exactly what makes
that file stale the moment the bundle is rebuilt. It refuses out loud when it that file stale the moment the bundle is rebuilt. It refuses out loud when it

View file

@ -512,6 +512,35 @@ def call_describe(surface: Surface, arguments: Mapping[str, Any]) -> dict[str, A
} }
def is_bundle(path: Path) -> bool:
"""Whether `path` IS a bundle rather than a folder that may hold some.
The rule discovery already uses to stop descending: a directory carrying
an `index.md`. The command line's two doors ask it to decide which shape
they were pointed at, so a reader never has to say which one it holds.
"""
return (path / "index.md").is_file()
def overview(surface: Surface) -> dict[str, Any]:
"""Every bundle under the roots and each one's card, as the command line
prints it for a FOLDER (`okf card <folder>`).
It is `okf_list` and `okf_describe` with no bundle named, joined, and it
computes nothing of its own: one source, two doors. The listing carries
what the cards do not -- the directory each bundle sits in, and the
directories that look like a bundle and cannot be read as one.
"""
listing = call_list(surface, {})
described = call_describe(surface, {})
return {
"shape": listing["shape"],
"bundles": listing["bundles"],
"unreadable": listing["unreadable"],
"cards": described["cards"],
}
def _questions(arguments: Mapping[str, Any]) -> list[str]: def _questions(arguments: Mapping[str, Any]) -> list[str]:
"""`question` (one string) or `questions` (a list), never both. """`question` (one string) or `questions` (a list), never both.

View file

@ -1003,15 +1003,31 @@ def card_main(argv: list[str] | None = None) -> int:
prog="okf card", prog="okf card",
description=( description=(
"Print one bundle's identity, concept count, conditional-field counts " "Print one bundle's identity, concept count, conditional-field counts "
"and whole-bundle cost as JSON. Derived from the bundle on every run." "and whole-bundle cost as JSON -- or, for a folder, every bundle under "
"it with its card. Derived from the bundles on every run."
),
)
parser.add_argument(
"bundle",
type=Path,
help=(
"the OKF bundle to describe, or a FOLDER: then every bundle under it "
"is listed with its card, as the server's `okf_list` and "
"`okf_describe` give them"
), ),
) )
parser.add_argument("bundle", type=Path, help="the OKF bundle to describe")
args = parser.parse_args(argv) args = parser.parse_args(argv)
from .mcp_server import card as build_card from . import mcp_server
try: try:
payload = build_card(args.bundle.resolve(), profile=okf_consume.DEFAULT_PROFILE) if args.bundle.is_dir() and not mcp_server.is_bundle(args.bundle):
surface = mcp_server.build_surface(bundle=None, roots=[args.bundle])
payload = mcp_server.overview(surface)
else:
payload = mcp_server.card(args.bundle.resolve(), profile=okf_consume.DEFAULT_PROFILE)
except mcp_server.ToolError as exc:
print(f"refused ({exc.code}): {exc}", file=sys.stderr)
return 1
except okf_consume.ConsumeError as exc: except okf_consume.ConsumeError as exc:
print(f"refused ({exc.code}): {exc}", file=sys.stderr) print(f"refused ({exc.code}): {exc}", file=sys.stderr)
return 1 return 1

View file

@ -0,0 +1,139 @@
"""One folder, every bundle under it: the command line's own door (v1.1 F).
The server has read a FOLDER of bundles since `okf mcp --root`; the generic
skill read one bundle at a time, the one it was pointed at, and could not see
the others. These tests hold the two command-line entries the skill now uses
-- `okf card <folder>` and `okf consume <folder>` -- to the server's OWN
functions: one source, two doors. Both corpora are invented here.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
from llm_ingestion_okf import mcp_server
from llm_ingestion_okf.cli import build
BAKERY = {
"surdeig.md": (
"# Surdeig\n\n"
"## Heving\n\n"
"Surdeigen hever i tolv timer ved romtemperatur før den formes.\n\n"
"## Steking\n\n"
"Brødet stekes i førti minutter på to hundre og tretti grader.\n"
),
}
GARDEN = {
"tomater.md": (
"# Tomater\n\n"
"## Vanning\n\n"
"Tomatene vannes hver morgen, og jorda skal aldri tørke helt ut.\n\n"
"## Oppbinding\n\n"
"Plantene bindes opp til en stokk når de er tretti centimeter høye.\n"
),
}
def _bundle(tmp_path: Path, folder: Path, name: str, documents: dict[str, str]) -> Path:
source = tmp_path / f"src-{name}"
source.mkdir()
for file_name, text in documents.items():
(source / file_name).write_text(text, encoding="utf-8")
target = folder / name
build(source, target, bundle_id=name, okf_version="0.2")
return target
@pytest.fixture(scope="module")
def folder(tmp_path_factory: pytest.TempPathFactory) -> Path:
tmp_path = tmp_path_factory.mktemp("folder-of-bundles")
root = tmp_path / "samlinger"
root.mkdir()
_bundle(tmp_path, root, "bakeri", BAKERY)
_bundle(tmp_path, root, "hage", GARDEN)
return root
def _okf(*argv: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-m", "llm_ingestion_okf.cli", *argv],
capture_output=True,
text=True,
check=False,
)
def _surface(folder: Path) -> mcp_server.Surface:
return mcp_server.build_surface(bundle=None, roots=[folder])
# --- F1: the overview -------------------------------------------------------
def test_the_card_of_a_folder_names_every_bundle_under_it(folder: Path) -> None:
run = _okf("card", str(folder))
assert run.returncode == 0, run.stderr
overview = json.loads(run.stdout)
assert [card["bundle_id"] for card in overview["cards"]] == ["bakeri", "hage"]
assert [entry["bundle_id"] for entry in overview["bundles"]] == ["bakeri", "hage"]
assert overview["unreadable"] == []
assert overview["shape"] == "one-to-many"
# Each card carries the map, which is what the working method reads first.
assert all(card["map"] for card in overview["cards"])
def test_the_card_of_a_folder_is_the_servers_own_listing_and_description(folder: Path) -> None:
"""One source: the bytes the command prints are the server's two replies."""
surface = _surface(folder)
listing = mcp_server.call_list(surface, {})
described = mcp_server.call_describe(surface, {})
overview = json.loads(_okf("card", str(folder)).stdout)
assert overview["bundles"] == listing["bundles"]
assert overview["unreadable"] == listing["unreadable"]
assert overview["cards"] == described["cards"]
def test_the_card_of_one_bundle_is_unchanged(folder: Path) -> None:
"""Pointed at one bundle, the command prints that bundle's card, as before."""
bundle = folder / "bakeri"
run = _okf("card", str(bundle))
assert run.returncode == 0, run.stderr
card = json.loads(run.stdout)
assert card == mcp_server.card(bundle.resolve(), profile=mcp_server.okf_consume.DEFAULT_PROFILE)
assert "cards" not in card
def test_a_folder_holding_no_bundle_is_refused_rather_than_empty(tmp_path: Path) -> None:
empty = tmp_path / "tom"
empty.mkdir()
run = _okf("card", str(empty))
assert run.returncode == 1
assert "bundle_none_served" in run.stderr
def test_a_broken_bundle_under_the_folder_is_reported(folder: Path, tmp_path: Path) -> None:
root = tmp_path / "med-feil"
root.mkdir()
for name in ("bakeri", "hage"):
(root / name).symlink_to(folder / name) # never followed: not listed
broken = root / "odelagt"
broken.mkdir()
(broken / "index.md").write_text("---\ntitle: x\n---\n", encoding="utf-8")
real = root / "ekte"
real.mkdir()
for source in (folder / "hage").rglob("*"):
target = real / source.relative_to(folder / "hage")
if source.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.write_bytes(source.read_bytes())
overview = json.loads(_okf("card", str(root)).stdout)
assert [card["bundle_id"] for card in overview["cards"]] == ["hage"]
assert overview["unreadable"] == [
{"directory": "odelagt", "reason": "index.md declares no bundle_id"}
]