Metadata-Version: 2.5
Name: ostler
Version: 2.0.0
Summary: Define, validate, search and mutate a repository's markdown knowledge graph (OKF profile: epics, stories, seeds, knowledge, features).
Project-URL: Homepage, https://github.com/GabrielCpp/stablemate
Project-URL: Repository, https://github.com/GabrielCpp/stablemate
Project-URL: Issues, https://github.com/GabrielCpp/stablemate/issues
Author: Gabriel Côté
License-Expression: MIT
License-File: LICENSE
Keywords: agents,docs,epics,knowledge,stories,validation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Documentation
Classifier: Topic :: Utilities
Requires-Python: >=3.12
Requires-Dist: jsonschema>=4.0
Requires-Dist: markdown-it-py>=3.0
Requires-Dist: mdit-py-plugins>=0.4
Requires-Dist: platformdirs>=4.0
Requires-Dist: pydantic>=2.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: tomli-w>=1.0
Requires-Dist: tree-sitter-language-pack>=1.14
Requires-Dist: tree-sitter<0.26,>=0.25
Requires-Dist: unidiff>=0.7
Provides-Extra: dev
Requires-Dist: pillow>=10.0; extra == 'dev'
Requires-Dist: playwright>=1.40; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Provides-Extra: qa
Requires-Dist: playwright>=1.40; extra == 'qa'
Provides-Extra: vet
Requires-Dist: pillow>=10.0; extra == 'vet'
Requires-Dist: playwright>=1.40; extra == 'vet'
Description-Content-Type: text/markdown

# ostler

> Tend your documentation graph.

`ostler` is the single system-of-record for a repository's `docs/` knowledge graph. It **defines,
validates, searches and mutates** your planning docs — backlog items, milestones, epics, stories,
seeds, features and specs —
as plain markdown **Concepts** (a strict profile of the
[Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)).

Everything is markdown. An epic's seeds and its story dependency-DAG live inside its `epic.md`; there
is **no** `seed.json`, `dependencies.json`, `inventory.json` or `epics-todo.json`. Ostler owns id
allocation and is the one tool that reads and writes the graph — so structure stays consistent while
humans (or agents) author the prose.

It is a standalone, repo-agnostic CLI that operates relative to the **current working directory**:
roots default to `<cwd>/docs/{epics,features,specs}` and the organization name to the repo
folder name. Point it at any repo with `-C/--chdir`.

The feature graph is also a contract: it is what
[coder's QA](https://github.com/GabrielCpp/stablemate/blob/main/workflows/README.md#coder-documentation-convergence)
holds the running application to, promise by promise. Ostler's part in that is detection —
structural findings, source units no node covers, citations whose declaration has changed
underneath them — which is what gives a repair loop something to converge on. It is not a
verdict that the behaviour worth documenting is documented, or that a declared check can
tell success from failure: a clean `doctor` is the floor, not the proof.

## Install

```bash
pipx install ostler          # recommended: isolated CLI on your PATH
# or
pip install ostler
```

For development against a local checkout:

```bash
pipx install --editable /path/to/stablemate/ostler --force
```

The package installs one console entry point, `ostler`.

## Quickstart

Ostler creates the *structure and ids*; you author the *content* into the skeletons it scaffolds.

```bash
# 1. See if the graph is healthy
ostler doctor

# 2. Capture intake and its release boundary (both allocate immutable ids)
ostler create backlog-item "Ship checkout parity" --section Scope --json
ostler create milestone checkout-mvp --title "Checkout MVP" \
  --source-items <full-backlog-id> --json

# 3. Scaffold an epic (allocates an id, writes docs/epics/0001-checkout-flow/epic.md —
#    the directory carries the order it was created in; `--json` reports the name it used)
ostler create epic checkout-flow --title "Checkout Flow at Parity"

# 4. Record a seed (a unit of intended work) in that epic's ## Seeds
ostler seed add checkout-flow address-step --status researched \
  --surface checkout/address --summary "Collect & validate the shipping address"

# 5. Cut a story that covers the seed (adds it to the epic's ## Stories, scaffolds story.md)
ostler create story checkout-flow 01-address-step \
  --title "Address step" --covers address-step

# 6. Ask what to work on next, then list the epic's stories as JSON
ostler next-story checkout-flow
ostler list --type story --epic checkout-flow --json
```

Then open the scaffolded `epic.md` / `story.md` and write the narrative, acceptance criteria, and
prose — ostler keeps the seeds, edges, ids and queue coherent around it.

A story may carry a provider-neutral `externalKey` in its own frontmatter when a tracker already
names the work. It is a lookup alias only: Ostler's generated `id` remains the immutable identity
used by new commit trailers and spec directories. IDs, slugs and external keys must be unique across
stories; `doctor` rejects ambiguity rather than letting graph order choose one.

## The hierarchy

A repository's knowledge lives under `docs/` as OKF **bundles** (directories of markdown Concepts).
A **Concept** is one `.md` file with a YAML **frontmatter** block (whose only hard requirement is a
non-empty `type`) and a markdown **body** using conventional headings.

**Identity is the path.** A Concept's id is its bundle-relative path without `.md`
(`docs/features/profile/preference-summary.md` → `profile/preference-summary`). The reserved
filenames `index.md` (an ordered listing of a bundle) and `log.md` (history) are not Concepts.

### Entity types

| `type` | Location (repo-relative) | Identity | Required frontmatter |
|---|---|---|---|
| `milestone` | `docs/milestones/<slug>.md` | generated `id`; readable `<slug>` names the file | `type`, `id`, `title` |
| `epic` | `docs/epics/<NNNN-slug>/epic.md` | `<NNNN-slug>` (dir name) | `type`, `id`, `title` |
| `story` | `docs/epics/<NNNN-slug>/stories/<slug>/story.md` | generated `id`; readable `<slug>` and optional `externalKey` are aliases | `type`, `slug`, `status` |
| `feature` | `docs/features/<area>/<slug>.md` *(or flat `docs/features/<slug>.md`)* | `<area>/<slug>` | `type`, `slug`, `title` |
| `spec.<stem>` (`spec.plan`, `spec.review`, `spec.qa`, `spec.executive`, `spec.vet`, …) | `docs/specs/<slug>/*.md` | path | `type` |

**Epic directories carry their creation order** — `create epic checkout-flow` writes
`docs/epics/0001-checkout-flow/`, so a listing of `docs/epics` reads as the work order rather than
as an alphabetized set. The number is *not* an identity (that is the minted `id`, which never
changes), so the bare slug still names the epic in every command: `ostler todo add checkout-flow`,
`--epic checkout-flow`, `create story checkout-flow …`. Use `ostler path epic <slug>` when you need
the directory itself, and read `--json`'s `name` back after `create epic` rather than assuming one.

`spec.*` Concepts are process artifacts: typed and conformance-checked, but ostler does not own their
internal schema. The subtype is the file's stem (`executive.md` → `spec.executive`); mint them with
`ostler create spec <slug> <doc>`, which is idempotent and also retro-stamps free-form docs.
**Not Concepts** (managed markdown, left in place): `docs/backlog.md` (an identified intake list)
and `docs/epics/index.md` (the epics queue).

### Planning intake identity

Use `create backlog-item` for new work. It allocates a full id and writes
`- [<full-id>] <text>` under the requested `##` section. If a person has already entered plain
bullets, `backlog adopt` assigns ids to every unnamed bullet without changing its prose or nesting.
The grammar is intentional: every bullet in the backlog is an item. Supporting context and detail
that should not acquire identity must be written as prose rather than as a list. Repeated adoption is
a no-op. Pruning a parent refuses while any nested item remains, so one item's completion cannot
silently discard another.

A milestone's generated `id` is independent of its readable filename and title. Its `sourceItems`
contains the full ids of the backlog intake it owns. Use `milestone set-source-items` to update that
set when an active milestone absorbs more intake. `doctor` rejects a backlog id owned by multiple
milestones. Never persist a short handle: handles are display/input conveniences and may lengthen
after a collision; Ostler resolves command inputs and writes full ids.

### `epic.md` — single source of truth for an epic

An epic's `epic.md` carries the narrative **and** its seeds and story dependency-DAG. Ostler parses two
canonical sections back out of the markdown by exact heading:

```markdown
---
type: epic
id: ACME-01JBXR7K9QZ4M2T8VNF3HD6PWC
title: Account Credits Billing Body
status: in-progress        # optional: planned | in-progress | done
---

Free narrative prose (any headings: ## Goal, ## Method, ## Acceptance, …).

## Seeds

### billing-landing-body
- status: researched       # backlog | researched | covered | resolved | dropped | deferred
- surface: account-billing/billing-body
- backing: GET /billing/customer → CustomerDetails

The first paragraph after the metadata bullets is the seed summary; further prose is free markdown.

## Stories

### 01-billing-body
- title: Account Credits Billing Body
- id: ACME-01JBXR7M4E0S9YCG5NAKQ2TZVJ
- covers: billing-landing-body, subscription-change-plan-link
- phase: 1
- effort: 8-10 hours
```

- `## Seeds` → `### <seed-id>` per seed (omit the whole section for a seedless epic).
- `## Stories` → `### <slug>` per story, carrying the **coverage edge** `covers:` (seed ids). The
  detailed spec lives in the story's own `story.md`, and so does its place in the DAG: a
  `## Dependencies` section stating one `- Blocked by: <sibling-slug>` per blocker, or the bare
  `(none)`. That way what blocks a story is readable in the story.

See [SPEC.md](https://github.com/GabrielCpp/stablemate/blob/main/ostler/SPEC.md) for the
authoritative, formal definition of every field, status enum, and conformance rule.

## Command interface

All read commands accept `--json`. Mutating commands allocate ids as needed and write canonical
markdown in place. `ostler --version` prints the version; `-C/--chdir DIR` runs any command as if
from `DIR`; `--handles` / `--full-ids` choose how ids are printed (see
[Short handles](#short-handles) — human output abbreviates, `--json` does not, and a handle is
accepted as input either way).

| Verbs | What they do |
|---|---|
| `doctor` `trace` | check conformance and referential integrity; walk the graph from any node |
| `list` `search` `query` `next-epic` `next-story` `path` | read the graph — what exists, what covers what, what to work on next |
| `create` `update` `delete` `seed` `set-status` `unblock` `backlog` `milestone` `todo` | mutate it — scaffold identified intake/plans/specs, revise story graph metadata, record a seed, move the queue |
| `edit` `freeze` `unfreeze` | repair a rename across the whole graph, or pin an approved story as ground truth |
| `template` `new` `find` `set` `remove` | declare a repo's own Concept kinds and operate on their instances |
| `graph` `reach` `locators` `coverage` `scaffold` `fmt` `vet` `audit` | the `docs/features/` node/edge book — see below |
| `qa` `artifact` | the verification control plane — see below |

`edit` is **dry-run unless `--write`**, so a rename across a whole graph is reviewable before it
happens; `create … --json` returns
`{"ok": true, "id": "<allocated-id>", "name": "<name-on-disk>", "message": "…"}` — `name` is what
`create epic` numbered the directory, which is why it is reported rather than assumed.

Every verb, with its real flags and what each one operates on, is in
[docs/CLI.md](https://github.com/GabrielCpp/stablemate/blob/main/ostler/docs/CLI.md).

### The feature graph

Alongside the epic/story planning graph, ostler tends `docs/features/` — a typed node/edge book
describing a product's actual surfaces (screens, components, endpoints, flows). `graph` queries it,
`reach` derives the documented click-path between two screens, `locators` emits the Playwright
locator for every documented control, `coverage` joins the book's `code:` citations against a source
inventory, and `vet` checks a rendered screenshot against what the book claims. The visual-fidelity
contract is in
[docs/VET.md](https://github.com/GabrielCpp/stablemate/blob/main/ostler/docs/VET.md).

`audit` prepares the two-way behavior review a reviewer then judges: file-local packets of
source candidates (returns, raises, routes, defaults) beside the book's normative claims. It
prepares, it never decides. Python is read with its own AST; Go, TypeScript, TSX and PHP
are read with the tree-sitter grammars `ostler.syntax` already carries — Go by its own
visitor, the others by one generic visitor driven from a per-language table in
`ostler.behavior_tree` (functions, containers, fields, returns, throws, framework-like route
and response calls, the branch headers that become a candidate's conditions, and the few
grammar spellings that differ: the modifier node, the default-value field, the call's name
path). Twig has no table and gets none: its grammar is flat — `{% endif %}` is a sibling of
`{% if %}`, not its parent — so a visitor that reads enclosure from the tree has nothing to
read, and a `.twig` file is reported `unsupported`. By default only **tier 1** candidates
enter a packet — a symbol the book cites, one the language exports (Go: capitalized;
Python: no leading underscore; TypeScript: the `export` keyword or a re-export clause, and
a class member without a `private`/`protected` modifier or a `#`/`_` name; PHP: every
top-level declaration, and a member without a `private`/`protected` modifier), or a
module-level statement, which has no name to keep private. The rest is tier 2, counted as
`deferred_candidates` and named in the file's packet limitations; `ostler audit --tier all`
reviews it too. The okf-builder audits tier 1: a private symbol's behavior reaches a caller
through some tier-1 symbol, and that is where a claim about it is checked. A claim whose
citations all name files that exist but were not selected is counted as `out_of_scope_claims`
and left for the audit that selects them; a citation to a file that does not exist stays a
reviewer's question. Each packet carries the book section around its claims, windowed to
forty lines either side, and only the extraction limitations of its own file.

Code in the documentation repository keeps the existing `path::symbol` spelling. A book that cites
a separate source repository qualifies the same reference with its stable workspace name:
`repo://api-service/internal/items.py::create_item`. Multi-repository context generation writes a
compact `docs/features/sources.json` catalog of file hashes and declarations, so `doctor` can ground
those citations without copying source trees or depending on machine-local checkout paths.

### Verification control plane

`ostler qa` owns the bookkeeping of a QA run. `qa context` turns a base/head diff into a
deterministic obligation scope for one story; `qa validate` and `qa run` then execute a version-2
plan that declares command, Playwright and Maestro targets and maps every scenario to
acceptance-criterion and OKF obligation ids. Validation rejects unknown coverage, unsupported actions
and locators, disposable pre-run inputs, literal secrets, and coverage without a machine assertion.
Each run starts with an empty `qa/`, writes an append-only ledger and content-hashed manifest, and
returns `passed`, `failed`, `blocked`, or `invalid`. `ostler artifact` schema-checks what a workflow
produces (a plan, a review resolution, a QA outcome) against a registered contract.

```bash
ostler qa context --base <rev> --head WORKTREE --spec docs/specs/<story> \
  --source-root web=web --source-root api=api --story-file docs/epics/.../story.md
ostler qa validate docs/specs/<story>/qa_plan.py --json
ostler qa run      docs/specs/<story>/qa_plan.py --json
ostler qa report       --spec docs/specs/<story>
ostler qa frames       --spec docs/specs/<story> --step <step-id>
ostler qa evidence-map --spec docs/specs/<story>
ostler qa sensitivity
```

Every run ends by rendering `<spec>/qa-report.md` from the ledger: one section per
acceptance criterion and per obligation with its verdict, the step each covering assertion
ran in, what it observed against what it expected, and the screenshots behind it — then every
scenario step by step, then the warnings that would let a rubber stamp through (a criterion
nothing covers, an assertion with no observed value, a scenario that stopped early). It is the
one file a reviewer reads to decide whether the work is real, and `qa report` re-renders it.
When the target was recorded, every step in it says where it sits in the video, and
`qa frames --step <step-id>` writes the frames around that moment as PNGs with an index.

After the run, `qa evidence-map` joins those four artifacts — the obligation scope, the
ledger, the manifest and the published verdict — into one row per obligation and a status:
`covered`, `claimed-but-unasserted`, `uncovered`, `unproven`, `insensitive`, or
`contradicted`. That last one is the
case worth naming: `qa-evidence.json` is a summary of the ledger, and where it publishes a
verdict the ledger does not hold, every consumer downstream reads the summary and none of
them goes back to check. `unproven` is its neighbour and its opposite: a scenario that died
mid-body observed nothing, so the obligation is unproven and the plan is what needs repairing —
scoring it as a disproof accuses the product of a defect the run never looked for. The whole
thing is a set difference, which is why it belongs in a command rather than in a reviewer's
instructions.

`insensitive` is the one status the set difference cannot see, and `qa sensitivity` is what
decides it. Every verifier is a pure function of what was observed, so each declared call can be
given a witness observation that satisfies it and then perturbed — the field the claim names
missing or holding something else, a different route answering, the ledger the write was
supposed to leave alone moved — with no app booted and no run required. A call no perturbation
reddens passes whatever the product does, and an obligation whose every call is like that is
green for a reason that has nothing to do with the product. The repair is the `verify:` bullet,
which is why the command reads the book alone and is worth running before any plan exists.

The run contract is in
[docs/QA-RUN.md](https://github.com/GabrielCpp/stablemate/blob/main/ostler/docs/QA-RUN.md); the
artifact contracts are in
[docs/ARTIFACT-CONTRACTS.md](https://github.com/GabrielCpp/stablemate/blob/main/ostler/docs/ARTIFACT-CONTRACTS.md).

## The parse index

`ostler doctor` re-reads and re-parses the same files on every invocation, and an agent that
checks its work often pays that cost dozens of times an hour. The parse index is a persistent,
content-addressed store that removes the repetition. **It is on by default**, and everything
about it is designed so that the worst thing a wrong index can do is be slow.

### What is cached, and what deliberately is not

Three products, and only three:

| Product | Key |
|---|---|
| **Parse products** — a document's frontmatter, sections, bullets, links and tables | the repo-name-qualified repo-relative path + the file's content sha |
| **Code-grounding symbol tables** — the symbol set extracted from a source file | the code file's content sha + the tree-sitter grammar version |
| **Behavior verdicts** — one reviewer's verdict on one claim or one source candidate (`ostler.behavior_memo`) | the review contract's digest + the item's content digest + the digest of the counterparts it was judged against + the excerpts the reviewer read, none of it positional |

All three are pure functions of bytes, which is what lets them be stored under a content key
with no invalidation rule beyond a single *epoch* hash over the global inputs (ostler's
version, the bundled schemas, the dynamic kind registry, the config files, the freeze
manifest). Change any of those and every entry is invalidated at once.

The verdict memo is the one product that is not ostler's own computation: it is what a model
said, stored so the okf-builder audit never asks twice. Its key says what could have moved the
answer — an edited claim drops its own verdict and every candidate's in its packet (their pool
of claims changed), an edited source file drops everything in that file, a changed prompt or
schema drops everything — and nothing else. A line shift hits; book evidence is stored relative
to its node and rebased on recall. The two-week prune applies to it as to every entry.

**Doctor's findings are not cached.** Nothing that a check *concluded* is ever served from the
index — only the parse products a check reads. And the **graph-global checks are always
recomputed**, on every run, never cached: reachability, the cross-epic seed and dependency
constraints, milestones, locators and the frozen-story check together cost around 0.06s, so
there is nothing to win by caching them and a whole class of transitive invalidation to lose.
The hard half of the invalidation problem is the half that had nothing to gain.

Writers stay uncached on purpose: the index serves the read-only document accessor, while the
commands that mutate a document parse it themselves. A shared parsed document handed to a
writer would be a live bug, not a cache hit.

### Where it lives, and how that is resolved

The directory is resolved in this order, first one wins:

1. `--index-dir DIR` — an explicit path on any command, including `ostler cache clean`;
2. `$OSTLER_INDEX_DIR` — the environment override, which is how a container points every tool
   in it at a cache copied in from the host;
3. `ostler_index_dir` in ostler's shared config;
4. the default: `ostler-index` under the shared stablemate cache (`~/.cache/stablemate/`).

The entry key holds the repo-relative path *qualified by the repo name*, not an absolute one,
so two worktrees of the same repo — and the same repo mounted into a container — share every
entry rather than each warming a partition of their own.

### Controls

```bash
ostler doctor --no-index                 # off for this run; the index is on by default
ostler doctor --index-dir /tmp/ix        # somewhere else for this run
ostler doctor --verify-index             # run both ways and diff the reports
ostler cache clean                       # evict entries not written for 14 days
ostler cache clean --all                 # remove everything, aged out or not
ostler cache clean --max-age-days 2 --json
```

`--no-index` is the escape hatch, present on every command; `--verify-index` is the correctness
gate — it runs doctor **with the index and without it in one command and diffs the two reports**,
exiting non-zero on any disagreement, so "cached and uncached agree" is something CI asserts
rather than something a README promises. Eviction has both paths: the explicit `cache clean`
above (`--all` removes every entry, not only the aged-out ones), and automatic age-based pruning
on write, so an unattended machine cannot grow the cache without limit. An entry's age is when it
was last **written**, not last read — a key names the exact bytes it was computed from, so an
entry still being read is one whose content has not moved, and evicting it costs the single
recomputation that writes it back. The sweep is a full directory walk, so it runs at most hourly
per index directory rather than on every write; the bound is an age, and an age bound does not
need checking at write granularity.

`doctor --json` reports what the index did, added to the report rather than substituted for any
of it:

```json
{ "…": "…",
  "index": { "dir": "/home/you/.cache/stablemate/ostler-index",
             "enabled": true, "hits": 1382, "misses": 0 } }
```

That is the line a disagreement between two runs is diagnosed from without instrumenting
anything: the same **hits**/**misses** against different directories is a different fault from
different counts against the same one. Under `--no-index` both counts read zero.

### It is content-keyed, so it is allowed to go stale

A host cache is refreshed by whatever happens to run ostler on the host; there is no warming
command and no freshness protocol, and a container never writes back to the cache it was given.
The consequence is worth stating plainly: **because every entry is content-keyed, a stale index
costs time and never correctness.** An entry either matches the bytes in front of it or it is
not consulted at all, so the failure mode of decay is a run that pays close to the cold price —
never a run that answers from an out-of-date parse. Deleting the whole directory at any moment
is safe for the same reason.

This is also why **every command that loads a graph populates the index, including the
read-only ones** (`graph`, `list`, `trace`, `reach`, `coverage`, …). Incidental use is the only
thing keeping a host cache warm between refreshes, and a read-only command that left it cold
would make the next `doctor` pay full price for no reason.

### Reproducing the timings

A speed claim nobody can re-derive is not evidence, so the profiling that steered this work is
committed as a harness:

```bash
make bench-doctor DOCS=/path/to/repo-holding-the-book        # human table
make bench-doctor DOCS=/path/to/repo-holding-the-book JSON=1 # a before/after diff
```

`DOCS=` names the repo holding the book and is **required — there is no default**: the measured
book lives outside this repo, and a baked-in path would measure whatever happened to be
underfoot. The harness reports cold and warm `model.load` and `doctor.run`, the per-check split,
the components inside `_check_ui`, and the book's shape (file count, bytes, UI nodes, feature
docs, link targets) alongside them, because a timing without the shape it was taken against is
not comparable to anything.

## Python API

The major graph and workflow-facing operations are available in-process through the
`Ostler` facade. Prefer it over spawning the CLI and parsing JSON when the operation is
available there: you load the graph once and get back plain objects instead of a
subprocess and a stdout scrape.

```python
from ostler import Ostler

okf = Ostler("path/to/repo")              # graph root discovered upward, like `-C DIR`
okf.next_story("checkout-flow")           # {"slug": …} | None
okf.create_story("checkout-flow", "02-pay", "Payment", covers=["seed-1"])
okf.doctor()                              # a QaOutcome; .data is the integrity report
```

Every check on the facade — `doctor`, `coverage`, and the whole qa/artifact family — answers
in that same `QaOutcome` (`ok`, `status`, `message`, `data`) rather than raising: a data-shaped
failure like an unreadable inventory or a book that will not load is *part of the answer*, not
an exception each caller re-catches. A call-site mistake still raises.

Two public modules come with it, and they are the reason nothing here reads a document with
a regex: **`ostler.markdown`** is the one markdown parser (frontmatter, sections, bullets,
GFM tables, links — never one inside a fence), and **`ostler.syntax`** is the tree-sitter
front end for Go, TypeScript/TSX, PHP and Twig that grounds `code:` citations and the
coverage join. The rule they serve is the `structured-parsing` skill in the base library,
enforced by `make check-parsers`.

The facade's snapshot semantics, the full call surface, and why the code side is tree-sitter
rather than each language's own toolchain are in
[docs/PYTHON-API.md](https://github.com/GabrielCpp/stablemate/blob/main/ostler/docs/PYTHON-API.md).

## The coverage model

```
story (epic.md ## Stories)  ->  covers: seed (epic.md ## Seeds)
```

`ostler doctor` checks OKF conformance (every Concept has a non-empty `type`) plus the typed
referential-integrity contract:

- **cross-epic references** — an id/slug used inside epic E that only resolves in another epic;
- **orphan seeds** — an active seed no story covers;
- **dangling references** — a knowledge path or sibling slug that resolves to nothing;
- **frozen drift** — an approved (frozen) story/seed that changed or vanished.

It exits non-zero when any error-level finding is present, so it drops straight into CI or a pre-commit
hook. Warning-level findings (e.g. `story-covers-no-seed`, `ungrounded-surface`) are reported but do
not fail the check.

## Id allocation

Ostler owns `.agents/ids.json` (`{prefix, frozen}`). `create backlog-item|milestone|epic|story|feature` allocates an id,
scaffolds the canonical markdown, and (for stories) inserts the `### <slug>` block into the epic's
`## Stories`. There is no external id allocator.

An id is `<PREFIX>-<ULID>` — the repo prefix (first four letters of the repo name, pinned on first
use) plus a monotonic ULID: 26 Crockford-Base32 chars encoding a millisecond timestamp and 80 bits
of randomness. It sorts by mint time and needs **no coordination**, so two worktrees, two processes
or two clones never collide and there is no counter to lock or merge. An id is an opaque string.

### Short handles

A 26-char id is not a thing anyone retypes, so ostler abbreviates it git-style to a **handle** —
`<PREFIX>-<6+ chars>`, the shortest slice unambiguous among the ids currently in the repo:

```bash
ostler list --type seed                # ACME-K3XQ7P    ← handles, the default for human output
ostler list --type seed --json         # ACME-01JB…     ← full ids, the default for --json
ostler --full-ids list --type seed     # full ids in human output
ostler --handles  list --type seed --json
```

The split is the point: a person wants a token short enough to copy, while a program wants the
identity that never changes. A handle **lengthens** the moment a colliding id is minted, so it is a
display form — never what gets written into a document.

Input is not modal. A handle is accepted wherever ostler takes an id, in either mode and from either
surface, so a token copied out of one command goes straight into the next:

```bash
ostler query stories-covering-seed ACME-K3XQ7P
ostler seed add checkout ACME-K3XQ7P --status resolved
ostler backlog prune ACME-K3XQ7P
```

The slice is of a *hash* of the ULID rather than of the id itself: monotonic ids minted in the same
millisecond differ only in their low bits, and hashing decorrelates them so even a burst abbreviates
to six characters. From Python, `okf.handle(id)` / `okf.handles()` render and `okf.expand(token)`
resolves — though every ostler entry point already expands its own id arguments.

## Story provenance

Provenance queries join exact Git `Story:` trailers with the generated context packet under a
story's spec directory. Git is authoritative for commits; `qa-okf-context.json` is authoritative
for the changed-unit-to-OKF impact calculated for that story. No separate ledger is written.

```bash
ostler query story-provenance TEAM-123 \
  --checkout api-service=/workspace/api-service --json
ostler query commit-story api-service@abc123 \
  --checkout api-service=/workspace/api-service --json
ostler query node-provenance docs/features/billing/create.md \
  --checkout api-service=/workspace/api-service --json
```

Checkout paths are inputs only and are never persisted. Missing checkouts or context packets are
reported as warnings rather than guessed from branch names, subjects, or current graph shape.

## Profiles

`ostler` infers a profile from the tree: **`full`** when `docs/epics` exists (the epic/story/seed/
knowledge coverage graph), **`exploration`** otherwise (knowledge/docs only, no coverage graph).
Override any default in an optional `organization:` block in `ostler.yml` / `agents.yml` at the repo
root.

## Templates (custom hierarchies)

The built-in types above (epic/story/knowledge/feature/spec) are fixed. For a *different*
documentation shape — your own Concept kinds, nesting, required fields, status enums — declare it
per-repo in **`.agents/templates.yml`** (git-tracked, alongside `.agents/ids.json`). A kind is live
for `new`/`find`/`set`/`remove`/`doctor` the moment it's written — no separate activation step.

```bash
ostler template new    <name> [kind ...]        # declare a template, optionally with stub kinds
ostler template edit   <name> --set <kind>.<field>[.<subfield>]=<value>
ostler template find   [<name>]                 # list templates, or one template's definition
ostler template delete <name>
ostler template apply  <name>                   # mkdir -p each doc_root + inject CLAUDE.md guidance
```

Once a template's kinds are declared, use the same generic verbs against instances:

```bash
ostler new    <kind> <name> [field=value ...]   # <parent-kind>=<name> scopes nesting
ostler find   <kind> [<name>]
ostler set    <kind> <name> field=value ...
ostler remove <kind> <name>
```

See
[SPEC.md §9](https://github.com/GabrielCpp/stablemate/blob/main/ostler/SPEC.md#9-templates-and-template-declared-kinds)
for the full YAML schema, a worked 3-level nesting example, and the bundle-vs-leaf shape rules.

## Versioning

The format is the OKF profile **v1.0**, versioned `<major>.<minor>`. Minor bumps add backward-compatible
fields; major bumps may change required frontmatter or the `epic.md` grammar. A repo may record
`okf_version` and `ostler_profile` in `docs/epics/index.md`.

## License

MIT. See [LICENSE](https://github.com/GabrielCpp/stablemate/blob/main/ostler/LICENSE).
