feat(readme,skill,cli): the first screen an agent reads, three modes, and one flag that made two builds

`okf project` built a bundle two rules behind `okf build`. `cli.build`'s
signature defaulted `keep_table_heading` and `sheet_section_rows` to `False`
while argparse defaulted both to `True`, and `project.create` calls `build()`
as a function. Measured on a five-document folder: 15 concepts / 30 files
where `okf build` wrote 26 / 52, the whole difference in the priced sheet --
the document a question about price has to reach. The invariant test could not
see it: it compared `project.create` against the same function, and its two
fixture documents had neither a table nor a sheet. Both gaps are tests now,
and the two paths are byte-equal on that folder (`diff -rq`, 0 differences).

README opens with what / one install line / two commands / the three shapes of
request; the phase-status paragraph moved down, nothing deleted. One tag is
pinned everywhere: README pinned v0.4.0 on its install lines and v0.6.0 below,
llms.txt pinned v0.4.0, so an agent reading from the top installed a tag
without `okf project`.

The skill states three modes -- question, hypothesis (per premise, `confirmed`
/ `refuted` / `undecidable-from-bundle`), and a task producing a document
(source per claim in the artefact, an ungrounded paragraph written and marked
rather than dropped, the cut declared inside the document). The five markings
are untouched.

Generated skills state relative paths in the project layout: `okf consume
.okf/<id>` and `okf check --skill .claude/skills/<id>-consume/SKILL.md`,
runnable from where `okf project` tells the reader to start `claude`. Two
absolute paths to zero, measured with a query shown capable of finding first --
O5's published "4 -> 0" used `grep -c "^/"` against paths indented by two
spaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-09 18:12:02 +02:00
commit 1260fac154
12 changed files with 532 additions and 43 deletions

View file

@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.7.0] — 2026-09-09
The first screen an agent reads, the three shapes of request the skill answers,
and one defect that made the command the first screen recommends build a worse
bundle than the command it claims to be.
### Fixed
- **`okf project` built a bundle two rules behind `okf build`.** `cli.build`'s
Python signature defaulted `keep_table_heading` and `sheet_section_rows` to
`False` while argparse defaulted both to `True`; `project.create` calls
`build()` as a function and passes no flag list, so it read the signature.
Measured on a five-document folder: `okf project` wrote **15 concepts / 30
files** where `okf build` on the same folder wrote **26 / 52**, and the whole
difference was in the priced spreadsheet — the document a question about price
has to reach. The invariant test that was supposed to catch this could not:
it compared `project.create` against the same `build()` function, so both
sides carried the same wrong value, and its two fixture documents had neither
a table nor a sheet. Both gaps are now tests: one compares the signature's
defaults against argparse's, the other builds a document whose concept count
actually moves with the two flags. After the fix the two paths are byte-equal
on that folder (`diff -rq`, 0 differences).
### Added
- **A first screen for a reader who has not used this before**, agent or human:
what it is in one sentence, one install line, two commands, and the three
shapes of request. The phase-status paragraph that used to open the README
moved down to `## What this library is`; nothing was deleted.
- **Three modes in the consumption skill**, stated in the template, the
instantiated skill and the generator:
- **Question** — as before, the default.
- **Hypothesis** — decomposed into premises and answered **per premise** as
`confirmed` / `refuted` / `undecidable-from-bundle`, three literals with no
fourth value. A premise whose excerpt is real but does not carry the
conclusion is `[sourced-not-sufficient]` on **that premise**, not on the
whole answer: four premises and one weak source is three answers and one
gap, and reporting it as one refusal throws the three away.
- **Task that produces a document or a paragraph** — every claim in the
written artefact carries `(bundle_id, concept_id)`, the excerpt's `sha256`,
its `title` and whichever `source_*` keys it has; an ungrounded paragraph is
**written and marked**, never dropped; and the cut (`considered`,
`withheld`, `delivered`) is declared inside the document, because the
document travels without the chat.
The five markings are untouched — the modes add no sixth.
### Changed
- **The generated skill states relative paths where it can.** In the layout
`okf project` writes, the commands are now `okf consume .okf/<id>` and
`okf check --skill .claude/skills/<id>-consume/SKILL.md`, runnable from the
project root — which is where `okf project`'s own closing line tells the
reader to start `claude`. A path outside the project root stays absolute on
purpose: `../../..` is not more portable, only harder to read. The two
absolute paths a generated skill carried are now zero, measured with a query
shown capable of finding first — O5's published "4 → 0" used `grep -c "^/"`
against paths indented by two spaces, which could not have matched either way.
- **One tag is pinned everywhere.** `README.md` pinned `v0.4.0` on its install
lines and `v0.6.0` further down, and `llms.txt` pinned `v0.4.0`; an agent
reading from the top installed a tag without `okf project`. All install lines
now name `v0.7.0`, and the earlier tags are kept as a labelled history
section rather than as commands. `llms.txt` gained the `okf project` form and
a pointer to the Claude Code section.
## [0.6.0] — 2026-09-08 ## [0.6.0] — 2026-09-08
The first tag since `v0.5.0a2`, so everything that had accumulated as The first tag since `v0.5.0a2`, so everything that had accumulated as

View file

@ -263,7 +263,40 @@ and fixtures, never code.
`<id>` to the folder name reduced to `[a-z0-9-]`. It owns NO flag that moves `<id>` to the folder name reduced to `[a-z0-9-]`. It owns NO flag that moves
a bundle's bytes and a test holds it byte-equal to `okf build`; two build a bundle's bytes and a test holds it byte-equal to `okf build`; two build
paths would leave every measurement report pinned to a bundle nobody paths would leave every measurement report pinned to a bundle nobody
produces. `skills/okf-prosjekt/` is the Claude Code skill over it. produces. **That invariant was FALSE from the day those two
flags became defaults until O6 measured it, and the test could not see it:** `cli.build`'s Python SIGNATURE defaulted
`keep_table_heading` and `sheet_section_rows` to `False` while argparse
defaulted both to `True`, and `project.create` calls `build()` as a function,
so it read the signature. Measured on a five-document folder, `okf project`
wrote **15 concepts / 30 files** against `okf build`'s **26 / 52**, the whole
difference in the priced spreadsheet -- the document a question about price
has to reach. The byte-equality test compared `project.create` against the
same `build()`, so both sides carried the same wrong value, and its two
fixture documents had neither a table nor a sheet: **a test and the code
agreeing over a set where the difference cannot appear.** Two tests now hold
it -- one comparing the signature's defaults against argparse's for every
same-typed parameter, one building a document whose concept count actually
moves with the two flags. `skills/okf-prosjekt/` is the Claude Code skill
over it.
- **The generated consumption skill states THREE modes and RELATIVE paths**
(O6, 2026-09-09). Question (the default), hypothesis (decomposed into
premises and answered PER PREMISE as `confirmed` / `refuted` /
`undecidable-from-bundle` -- three literals, no fourth; a weak source is
`[sourced-not-sufficient]` on that PREMISE, because four premises and one
weak source is three answers and one gap), and a task producing a document
(source per claim IN the artefact, an ungrounded paragraph written and marked
rather than dropped, the cut declared inside the document because the
document travels without the chat). The five markings are untouched -- the
modes add no sixth. In the layout `okf project` writes, the commands are
`okf consume .okf/<id>` and `okf check --skill
.claude/skills/<id>-consume/SKILL.md`, runnable from the project root, which
is where `okf project`'s own closing line tells the reader to start `claude`;
a path OUTSIDE that root stays absolute on purpose, since `../../..` is not
more portable, only harder to read. Two absolute paths to zero -- and
**O5's published "4 absolute paths -> 0" was measured with `grep -c "^/"`
against paths indented by two spaces**, a query that could not have found one
either way, so the zero was never a measurement. Every path assertion here
runs its pattern against a known-positive first.
- Build a bundle: `okf build <folder> --bundle <dir> --bundle-id <id> - Build a bundle: `okf build <folder> --bundle <dir> --bundle-id <id>
--okf-version <v>` — the installed console script (`[project.scripts]`), --okf-version <v>` — the installed console script (`[project.scripts]`),
the packaged form of what used to be a shell loop over two `tools/` the packaged form of what used to be a shell loop over two `tools/`

View file

@ -1,6 +1,41 @@
# llm-ingestion-okf # llm-ingestion-okf
Shared OKF (Open Knowledge Format) ingestion library: spec-based connectors, bundle inbox, and external-bundle import. Security delegated to llm-ingestion-guard. Turn a folder of documents (PDF, DOCX, XLSX, PPTX, MD) into a bundle a model can
answer from **with a source on every claim** — offline, deterministic, no model
call anywhere in the run path.
## Install
Python 3.10+ and [uv](https://docs.astral.sh/uv/). One line:
```sh
uv tool install "llm-ingestion-okf[extract] @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.7.0"
```
## Use it
```sh
okf project ~/my-documents # folder in: bundle + Claude Code skill, in this directory
claude # start Claude Code here
```
Then ask in plain language. Three shapes of request work, and the skill states
the rules for each:
- **a question** — "hva er kravene til pris?"
- **a hypothesis** — "stemmer det at leverandoeren baerer risikoen for grunnforhold?"
Answered per premise as `confirmed` / `refuted` / `undecidable-from-bundle`.
- **a task whose answer is a document** — "lag `krav-pris.md` med alle krav til
pris, ett avsnitt per krav, med dokument og kravnummer." Every claim in the
written file carries its source; a paragraph with no ground is written
and marked, never dropped.
Everything below is detail: [Consume in Claude
Code](#consume-in-claude-code) for the same thing in steps and with several
bundles at once, [Build](#build) for the flags, [Requirements](#requirements)
for the pip fallback and the guard pairing.
## What this library is
Status: phases 13 are implemented. Phase 1 (spec-based ingestion) covers Status: phases 13 are implemented. Phase 1 (spec-based ingestion) covers
manifest validation, the `file`/`sql`/`http` connectors, deterministic manifest validation, the `file`/`sql`/`http` connectors, deterministic
@ -17,46 +52,53 @@ formats through a vendored document converter. Three of those five office
rows are **unmeasured** — see [Binary extraction](#binary-extraction). Phase 4 rows are **unmeasured** — see [Binary extraction](#binary-extraction). Phase 4
(the Node half) is planned (see `docs/plan/`). (the Node half) is planned (see `docs/plan/`).
## Install ## Install in detail
Python 3.10+. Neither this package nor the guard it depends on is on a package Neither this package nor the guard it depends on is on a package index yet, so
index yet. With uv, one command is enough: both install by direct reference. With uv, one command resolves both:
``` ```sh
uv pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.4.0" uv pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.7.0"
``` ```
uv resolves the guard on its own, because it reads the `[tool.uv.sources]` uv resolves the guard on its own, because it reads the `[tool.uv.sources]`
entry in the `pyproject.toml` **of the tag it is installing**, and `v0.4.0` entry in the `pyproject.toml` **of the tag it is installing**, and `v0.7.0`
points that entry at the guard tag below. Measured 2026-07-25 and re-measured points that entry at `llm-ingestion-guard` `v1.3.0`. Use `uv tool install`
2026-08-20 with an empty `uv` cache; both runs installed instead of `uv pip install` when you want the `okf` command on `PATH` without an
`llm-ingestion-guard==0.2.0` + `llm-ingestion-okf==0.4.0` and imported clean. active virtualenv — that is the form the first screen shows.
With plain pip, the transitive git dependency does not resolve on its own — With plain pip, the transitive git dependency does not resolve on its own —
**install the guard first**, or installing this package fails with **install the guard first**, or installing this package fails with
`No matching distribution found for llm-ingestion-guard`: `No matching distribution found for llm-ingestion-guard`:
``` ```sh
pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.2.0" pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v1.3.0"
pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.4.0" pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.7.0"
``` ```
The guard tag is paired to the okf tag, not to this branch: `v0.4.0` declares The guard tag is paired to the okf tag, not to this branch. `v0.7.0` declares
`llm-ingestion-guard>=0.2,<0.3`, which `v0.2.0` satisfies and later guard tags `llm-ingestion-guard>=1.2,<2.0`, which `v1.3.0` satisfies; the pairing above is
do not. `main` has since moved its own pin to `>=1.2,<2.0` (see read off that tag's own `[tool.uv.sources]`, not off this branch. Reading a pin
[Requirements](#requirements)); that pin reaches you in the next stable tag, off `main` and installing it against an older okf tag is the one combination
not in the commands above. Reading a pin off this branch and installing it that fails.
against `v0.4.0` is the one combination that fails.
`v0.6.0` is the current tag and the one the three-line form under [Consume in ### Earlier tags, as history
Claude Code](#consume-in-claude-code) installs: it is the first tag carrying the
`okf project`, `okf consume`, `okf check` and `okf skill` subcommands, without
which that form does not exist. `v0.4.0` is the last tag before the OKF v0.2
work. `v0.5.0a2` is a pre-release for the named OKF v0.2 pilot set only.
**`v0.6.0` does not make OKF v0.2 generally available.** `OKF_LATEST` is These are not install lines. They record what each earlier tag was, so a reader
unchanged and still points at `DEFAULT`; flipping that alias is the GA event and who meets one in an older document knows what they are looking at.
this tag is not it (see [Upstream OKF versions](#upstream-okf-versions)).
- `v0.7.0` — the current tag: `okf project` builds the bundle `okf build`
builds (they were one flag apart before it), and the generated skill states
the question, hypothesis and document-task modes with relative paths.
- `v0.6.0` — the first tag carrying the `okf project`, `okf consume`,
`okf check` and `okf skill` subcommands.
- `v0.5.0a2` — a pre-release for the named OKF v0.2 pilot set only.
- `v0.4.0` — the last tag before the OKF v0.2 work; it declares
`llm-ingestion-guard>=0.2,<0.3`, which only guard `v0.2.0` satisfies.
**No tag yet makes OKF v0.2 generally available.** `OKF_LATEST` is unchanged and
still points at `DEFAULT`; flipping that alias is the GA event and none of the
tags above is it (see [Upstream OKF versions](#upstream-okf-versions)).
## Build ## Build
@ -317,7 +359,7 @@ not need this repository — the first line installs the command, the second
builds the bundle and writes a skill beside it, the third asks. builds the bundle and writes a skill beside it, the third asks.
```sh ```sh
uv tool install "llm-ingestion-okf[extract] @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.6.0" uv tool install "llm-ingestion-okf[extract] @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.7.0"
okf project ~/my-documents okf project ~/my-documents
claude claude
``` ```

View file

@ -1,15 +1,34 @@
# llm-ingestion-okf # llm-ingestion-okf
> Shared OKF (Open Knowledge Format) ingestion library: spec-based connectors, bundle inbox, and external-bundle import. Security delegated to llm-ingestion-guard. > Turn a folder of documents (PDF, DOCX, XLSX, PPTX, MD) into a bundle a model can answer from with a source on every claim. Offline and deterministic: no model call anywhere in the run path. Security is delegated to llm-ingestion-guard.
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/). Neither this package Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/). Neither this package
nor the guard it depends on is on a package index yet; with uv, one command nor the guard it depends on is on a package index yet; one command resolves both:
resolves both:
``` ```sh
uv pip install "llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.4.0" uv tool install "llm-ingestion-okf[extract] @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.7.0"
``` ```
## Optional Then, in the folder you want to work from:
- [README](README.md): the pip-only fallback if uv is unavailable, phase status, requirements, and the upstream OKF version policy. ```sh
okf project ~/my-documents
claude
```
`okf project` writes the bundle to `.okf/<id>/` and a Claude Code skill to
`.claude/skills/<id>-consume/` in the current directory. Start `claude` there
and ask in plain language. Three shapes of request are supported and the
generated skill states the rules for each: a **question**, a **hypothesis**
(answered per premise as `confirmed` / `refuted` / `undecidable-from-bundle`),
and a **task whose answer is a document** (every claim in the written file
carries its source; an ungrounded paragraph is written and marked, never
dropped).
## Docs
- [Consume in Claude Code](README.md#consume-in-claude-code): the same thing in
steps, several bundles in one project, and what was measured.
- [README](README.md): the pip-only fallback if uv is unavailable, the guard
pairing per tag, the build flags, phase status, and the upstream OKF version
policy.

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "llm-ingestion-okf" name = "llm-ingestion-okf"
version = "0.6.0" version = "0.7.0"
description = "Shared OKF (Open Knowledge Format) ingestion library: spec-based connectors, bundle inbox, and external-bundle import, with security delegated to llm-ingestion-guard." description = "Shared OKF (Open Knowledge Format) ingestion library: spec-based connectors, bundle inbox, and external-bundle import, with security delegated to llm-ingestion-guard."
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View file

@ -48,6 +48,59 @@ and the cut; it decides nothing about the question.
when you were handed a bounded window, is the denominator failure below with when you were handed a bounded window, is the denominator failure below with
extra steps. extra steps.
## Modes
Three shapes of request, one discipline. Which one you are in is decided by what
was asked, never by what the payload happened to contain.
### Question
Answer it from the delivered excerpts, mark every claim, and stop. The default.
### Hypothesis
A hypothesis is a claim someone wants tested, not a question. **Decompose it
into its premises first and answer PER PREMISE** — a single verdict over the
whole hypothesis hides which part the bundle actually covered.
Each premise gets exactly one of three literals:
| Verdict | Use when |
|---|---|
| `confirmed` | the delivered excerpts carry the premise |
| `refuted` | the delivered excerpts carry its contradiction |
| `undecidable-from-bundle` | neither, within what was delivered |
These three are literals, like the five markings: no fourth value, no
"partly confirmed", no translation. A premise whose excerpt is real but does not
carry the conclusion is marked `[sourced-not-sufficient]` **on that premise**,
not on the whole answer — a hypothesis with four premises and one weak source
has three answers and one gap, and reporting it as one refusal throws the three
away.
The hypothesis-level verdict is then stated as a consequence of the per-premise
ones, with its reasoning shown. It is `derived`, never `extracted`.
### Task that produces a document or a paragraph
Some requests want a written artefact — a note, a section, a table of
requirements — rather than an answer in chat. The artefact is held to the same
rule as an answer, in the artefact itself:
- **Every claim carries its source in the document**: `(bundle_id, concept_id)`,
the excerpt's `sha256`, its `title`, and whichever `source_*` keys that
excerpt has. A footnote, a parenthesis or a trailing line all work; leaving it
out because "the chat already said it" does not — the document is what gets
read, forwarded and quoted, and it travels without the chat.
- **A paragraph with no ground is written, not dropped.** Mark it
`[sourced-not-sufficient]` and leave it standing where it belongs, saying what
was asked for and what the bundle did not carry. A silently omitted section is
the denominator failure with a nicer surface: the reader cannot see the hole,
so they read a complete document.
- **Declare the cut inside the document**, not only in chat: `considered`,
`withheld` and `delivered`, plus the bundle ref. The three counts and the ref
are what let a later reader tell whether the document is still current.
## Markings ## Markings
Every claim carries exactly one of these five literals, plus a pointer to the Every claim carries exactly one of these five literals, plus a pointer to the

View file

@ -76,6 +76,59 @@ library's line-oriented parser can read, so no tier could be derived honestly),
the delivered cap), `over_budget_after_knapsack` (it fitted alone but not the delivered cap), `over_budget_after_knapsack` (it fitted alone but not
alongside the set that was chosen). alongside the set that was chosen).
## Modes
Three shapes of request, one discipline. Which one you are in is decided by what
was asked, never by what the payload happened to contain.
### Question
Answer it from the delivered excerpts, mark every claim, and stop. The default.
### Hypothesis
A hypothesis is a claim someone wants tested, not a question. **Decompose it
into its premises first and answer PER PREMISE** — a single verdict over the
whole hypothesis hides which part the bundle actually covered.
Each premise gets exactly one of three literals:
| Verdict | Use when |
|---|---|
| `confirmed` | the delivered excerpts carry the premise |
| `refuted` | the delivered excerpts carry its contradiction |
| `undecidable-from-bundle` | neither, within what was delivered |
These three are literals, like the five markings: no fourth value, no
"partly confirmed", no translation. A premise whose excerpt is real but does not
carry the conclusion is marked `[sourced-not-sufficient]` **on that premise**,
not on the whole answer — a hypothesis with four premises and one weak source
has three answers and one gap, and reporting it as one refusal throws the three
away.
The hypothesis-level verdict is then stated as a consequence of the per-premise
ones, with its reasoning shown. It is `derived`, never `extracted`.
### Task that produces a document or a paragraph
Some requests want a written artefact — a note, a section, a table of
requirements — rather than an answer in chat. The artefact is held to the same
rule as an answer, in the artefact itself:
- **Every claim carries its source in the document**: `(bundle_id, concept_id)`,
the excerpt's `sha256`, its `title`, and whichever `source_*` keys that
excerpt has. A footnote, a parenthesis or a trailing line all work; leaving it
out because "the chat already said it" does not — the document is what gets
read, forwarded and quoted, and it travels without the chat.
- **A paragraph with no ground is written, not dropped.** Mark it
`[sourced-not-sufficient]` and leave it standing where it belongs, saying what
was asked for and what the bundle did not carry. A silently omitted section is
the denominator failure with a nicer surface: the reader cannot see the hole,
so they read a complete document.
- **Declare the cut inside the document**, not only in chat: `considered`,
`withheld` and `delivered`, plus the bundle ref. The three counts and the ref
are what let a later reader tell whether the document is still current.
## Markings ## Markings
Every claim carries exactly one of these five literals, plus a pointer to the Every claim carries exactly one of these five literals, plus a pointer to the

View file

@ -76,7 +76,7 @@ from .manifest import (
) )
from .materialize import IngestResult, materialize_bundle from .materialize import IngestResult, materialize_bundle
__version__ = "0.6.0" __version__ = "0.7.0"
__all__ = [ __all__ = [
"BlockedFile", "BlockedFile",

View file

@ -230,8 +230,8 @@ def _propose_plans(
outline_run: int = 0, outline_run: int = 0,
table_grid: bool = False, table_grid: bool = False,
unit_fold: bool = False, unit_fold: bool = False,
keep_table_heading: bool = False, keep_table_heading: bool = DEFAULT_KEEP_TABLE_HEADING,
sheet_section_rows: bool = False, sheet_section_rows: bool = DEFAULT_SHEET_SECTION_ROWS,
drop_wrapped_outline: bool = False, drop_wrapped_outline: bool = False,
outline_gate: bool = False, outline_gate: bool = False,
first_span_from_zero: bool = False, first_span_from_zero: bool = False,
@ -301,8 +301,8 @@ def build(
outline_run: int = DEFAULT_OUTLINE_RUN, outline_run: int = DEFAULT_OUTLINE_RUN,
table_grid: bool = DEFAULT_TABLE_GRID, table_grid: bool = DEFAULT_TABLE_GRID,
unit_fold: bool = DEFAULT_UNIT_FOLD, unit_fold: bool = DEFAULT_UNIT_FOLD,
keep_table_heading: bool = False, keep_table_heading: bool = DEFAULT_KEEP_TABLE_HEADING,
sheet_section_rows: bool = False, sheet_section_rows: bool = DEFAULT_SHEET_SECTION_ROWS,
drop_wrapped_outline: bool = DEFAULT_DROP_WRAPPED_OUTLINE, drop_wrapped_outline: bool = DEFAULT_DROP_WRAPPED_OUTLINE,
outline_gate: bool = DEFAULT_OUTLINE_GATE, outline_gate: bool = DEFAULT_OUTLINE_GATE,
first_span_from_zero: bool = DEFAULT_FIRST_SPAN_FROM_ZERO, first_span_from_zero: bool = DEFAULT_FIRST_SPAN_FROM_ZERO,

View file

@ -273,6 +273,45 @@ def directory_control(bundle_root: Path, *, profile: BundleProfile) -> tuple[int
return walked, on_disk return walked, on_disk
# --- Where the reader runs the commands (O6) -----------------------------------
#: `okf project` writes the skill to `<root>/.claude/skills/<id>-consume` and
#: the bundle to `<root>/.okf/<id>`, then tells the reader to start `claude` in
#: `<root>`. So `<root>` is where the skill's commands are read from, and a path
#: written relative to it is the one a reader can paste.
SKILLS_LAYOUT = (".claude", "skills")
def project_root_of(out: Path) -> Path | None:
"""`<root>/.claude/skills/<name>` -> `<root>`; anything else -> None.
Read off the path rather than passed in, so `okf skill` and `okf project`
reach the same answer without a second parameter that could disagree with
the layout on disk.
"""
resolved = out.resolve()
if resolved.parts[-3:-1] == SKILLS_LAYOUT:
return resolved.parents[2]
return None
def as_written(path: Path, *, base: Path | None) -> str:
"""The path as the skill states it: relative to `base` when it is under it.
A path outside `base` stays absolute on purpose. `../../..` is not more
portable than `/Users/...`, it is only harder to read, and a skill that
states a path its reader cannot resolve is worse than one that states an
honest absolute.
"""
resolved = path.resolve()
if base is None:
return str(resolved)
try:
return resolved.relative_to(base).as_posix()
except ValueError:
return str(resolved)
# --- The instantiation -------------------------------------------------------- # --- The instantiation --------------------------------------------------------
@ -321,10 +360,11 @@ def render(
name = f"{slug(bundle_id)}-consume" name = f"{slug(bundle_id)}-consume"
text = template_path().read_text(encoding="utf-8") text = template_path().read_text(encoding="utf-8")
text = text.split("---\n", 2)[2] text = text.split("---\n", 2)[2]
base = project_root_of(out)
text = _rewrite( text = _rewrite(
text, text,
bundle_root=bundle_root, bundle_root=Path(as_written(bundle_root, base=base)),
skill_path=out.resolve() / "SKILL.md", skill_path=Path(as_written(out / "SKILL.md", base=base)),
bundle_id=bundle_id, bundle_id=bundle_id,
ref=ref, ref=ref,
name=name, name=name,

View file

@ -9,6 +9,7 @@ build paths and the reports are pinned to one of them.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import inspect
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -20,6 +21,7 @@ sys.path.insert(0, str(PROJECT_ROOT / "src"))
from llm_ingestion_okf import project # noqa: E402 from llm_ingestion_okf import project # noqa: E402
from llm_ingestion_okf.cli import build # noqa: E402 from llm_ingestion_okf.cli import build # noqa: E402
from llm_ingestion_okf.cli import parse_args # noqa: E402
from llm_ingestion_okf.cli import main as okf_main # noqa: E402 from llm_ingestion_okf.cli import main as okf_main # noqa: E402
from llm_ingestion_okf.errors import IngestError # noqa: E402 from llm_ingestion_okf.errors import IngestError # noqa: E402
@ -185,3 +187,81 @@ def test_the_installed_command_reaches_every_subcommand() -> None:
assert listed.returncode == 0 assert listed.returncode == 0
for command in ("build", "consume", "check", "skill", "project"): for command in ("build", "consume", "check", "skill", "project"):
assert command in listed.stdout, command assert command in listed.stdout, command
# --- The two defaults that were one flag apart (O6) ----------------------------
def test_the_build_signature_defaults_are_the_build_command_defaults() -> None:
"""One flag, one default. `okf project` reads the SIGNATURE, not argparse.
`project.create` calls `build()` as a Python function and passes no flag
list, so every segmentation value it gets is the signature's default. When
a flag moves to ON in argparse and is left OFF in the signature, there are
two defaults for one flag and `okf project` builds a bundle a rule behind
the command of the same name.
The byte-equality test above cannot see this: it calls the same function
with the same signature, so both sides carry the same wrong value. That is
the mechanism -- a test and the code agreeing over a set where the
difference cannot appear.
"""
parsed = parse_args(["build", "folder", "--bundle", "b", "--okf-version", "0.2"])
signature = inspect.signature(build)
disagreeing = {
name: (parameter.default, getattr(parsed, name))
for name, parameter in signature.parameters.items()
if hasattr(parsed, name)
and parameter.default is not inspect.Parameter.empty
and type(getattr(parsed, name)) is type(parameter.default)
}
disagreeing = {name: pair for name, pair in disagreeing.items() if pair[0] != pair[1]}
assert disagreeing == {}
def test_a_sheet_reaches_the_project_bundle_as_it_reaches_the_build_command(
tmp_path: Path,
) -> None:
"""The same folder through both entry doors, on a document that separates them.
`--sheet-section-rows` and `--keep-table-heading` are ON in `okf build`.
A markdown table under a heading is the smallest document whose concept
count moves with them, so this test's set is not empty by construction --
which is what let the invariant above pass while it was false.
"""
folder = tmp_path / "Ark"
folder.mkdir()
(folder / "krav.md").write_text(
"# Prisskjema\n\n"
"## 1 Poster\n\n"
"| Post | Beskrivelse | Pris |\n|---|---|---|\n"
"| 01 | Rigg og drift | 100 |\n"
"| 02 | Grunnarbeid | 200 |\n"
"| 03 | Betong | 300 |\n"
"| 04 | Staal | 400 |\n"
"| 05 | Tak | 500 |\n\n"
"## 2 Vilkaar\n\nBetaling skjer etter levering.\n",
encoding="utf-8",
newline="",
)
out = tmp_path / "project"
bundle, _, _ = project.create(folder, out=out)
reference = tmp_path / "reference"
assert (
okf_main(
[
"build",
str(folder),
"--bundle",
str(reference),
"--bundle-id",
"ark",
"--okf-version",
"0.2",
]
)
== 0
)
assert tree(bundle) == tree(reference)

View file

@ -11,6 +11,7 @@ from __future__ import annotations
import json import json
import re import re
import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -252,3 +253,107 @@ def test_the_generated_skill_carries_a_payload_its_own_bundle_produced(tmp_path:
assert payload["bundle"]["bundle_id"] == okf_consume.root_bundle_id_of(PROVENANCE) assert payload["bundle"]["bundle_id"] == okf_consume.root_bundle_id_of(PROVENANCE)
assert payload["bundle"]["ref"] == okf_consume.bundle_ref(PROVENANCE) assert payload["bundle"]["ref"] == okf_consume.bundle_ref(PROVENANCE)
assert okf_contract_check.check(written.read_text(encoding="utf-8"), payload).findings == () assert okf_contract_check.check(written.read_text(encoding="utf-8"), payload).findings == ()
# --- The three modes, and the paths a reader can actually run (O6) -------------
#: A path is absolute here if it starts a line or follows whitespace. The
#: measurement O5 published used `grep -c "^/"`, which cannot match a path
#: indented by two spaces -- which is the form the generator writes. Every
#: assertion below runs this pattern against a known-positive first.
ABSOLUTE = re.compile(r"(?:^|[ \t])(/[A-Za-z])", re.MULTILINE)
def _project_layout(tmp_path: Path, bundle: Path) -> tuple[Path, Path, str]:
"""Generate into the layout `okf project` writes, and return the pieces."""
root = tmp_path / "prosjekt"
identity = okf_consume.root_bundle_id_of(bundle)
inside = root / ".okf" / identity
inside.parent.mkdir(parents=True)
shutil.copytree(bundle, inside)
out = root / ".claude" / "skills" / f"{identity}-consume"
written = _generate(inside, out)
return root, written, identity
def test_the_pattern_that_looks_for_absolute_paths_can_find_one(tmp_path: Path) -> None:
"""Face 4 first: a query is shown capable of finding before a zero is read.
O5's own published figure ("4 absolute paths -> 0") was measured with
`grep -c "^/"` against a file whose paths are indented by two spaces, so the
query could not have found one either way. This is that control.
"""
known_positive = "prose with no path\n /Users/x/bundle\nokf consume /Users/x/other\n"
assert len(ABSOLUTE.findall(known_positive)) == 2
assert len(re.findall(r"^/", known_positive, re.MULTILINE)) == 0
@pytest.mark.parametrize("bundle", BUNDLES, ids=lambda path: path.name)
def test_a_project_skill_names_its_bundle_and_itself_relative_to_the_project_root(
bundle: Path, tmp_path: Path
) -> None:
"""The commands have to be runnable where `claude` is started: the project root.
`okf project`'s own closing line tells the reader to start `claude` in the
project root, so the skill's commands are read from there. An absolute path
makes the skill unmovable, unshareable, and wrong for anyone whose clone
lives elsewhere.
"""
root, written, identity = _project_layout(tmp_path, bundle)
text = written.read_text(encoding="utf-8")
assert f".okf/{identity}" in text
assert f".claude/skills/{identity}-consume/SKILL.md" in text
assert str(root) not in text
# Every absolute path left is a scratch write target, not a path into the
# machine the skill was generated on. `/tmp/payload.json` is where the
# pre-pass puts its payload; naming it relative would litter the project.
leftover = [line for line in text.splitlines() if ABSOLUTE.search(line) and "/tmp/" not in line]
assert leftover == []
@pytest.mark.parametrize("bundle", BUNDLES, ids=lambda path: path.name)
def test_the_generated_skill_declares_the_hypothesis_mode(bundle: Path, tmp_path: Path) -> None:
"""A hypothesis is answered per premise, not as one verdict over the whole.
The three verdicts are literals, like the five markings: a reader that
invents a fourth ("partly confirmed") has left the contract.
"""
_, written, _ = _project_layout(tmp_path, bundle)
text = written.read_text(encoding="utf-8")
assert "## Modes" in text
for literal in ("`confirmed`", "`refuted`", "`undecidable-from-bundle`"):
assert literal in text
assert "per premise" in text.lower()
assert "[sourced-not-sufficient]" in text
@pytest.mark.parametrize("bundle", BUNDLES, ids=lambda path: path.name)
def test_the_generated_skill_declares_the_document_task_mode(bundle: Path, tmp_path: Path) -> None:
"""A task whose answer is a document carries the cut INTO the document.
Dropping an ungrounded paragraph silently is the denominator failure with
a nicer surface: the reader cannot see what the bundle did not cover.
"""
_, written, _ = _project_layout(tmp_path, bundle)
text = written.read_text(encoding="utf-8")
assert "## Modes" in text
assert "Task" in text
for token in ("considered", "withheld", "delivered"):
assert token in text
def test_the_five_markings_are_untouched_by_the_modes(tmp_path: Path) -> None:
"""The modes add no sixth marking. § 4.3 makes an undeclared extension the defect."""
_, written, _ = _project_layout(tmp_path, GOLDEN)
text = written.read_text(encoding="utf-8")
for literal in okf_contract_check.REQUIRED_MARKINGS:
assert literal in text
def test_a_project_skill_still_passes_the_contract_checker(tmp_path: Path) -> None:
"""Relative paths and a new section must not cost conformance."""
_, written, _ = _project_layout(tmp_path, GOLDEN)
payload = okf_consume.build_payload(GOLDEN, question="hva er kravet til pris?")
report = okf_contract_check.check(written.read_text(encoding="utf-8"), payload)
assert report.findings == ()