Data Model: Common Docs query — CLI retrieval index

Module layering (packaging-critical)

scripts/ is not shipped in the wheel (pyproject.toml packages only src/); the installed spec-kitty CLI cannot import scripts.. Therefore the schema + query surface the CLI needs live in a packaged module, and the generator (build-tooling) imports down into it:

SymbolHomeImported by
Anchor, DocsQueryEntry, render_index, parse_index, compare_index, IndexDrift, DocsIndexStore, DEFAULT_INDEX_PATHsrc/specify_cli/docs/index_model.py (packaged)the CLI (src→src), the generator (scripts→src)
generate_index, scan_headings, resolve_title, resolve_abstract, slug_for_headings, run_generate_and_compare, __main__scripts/docs/docs_index.py (build-tooling)the freshness gate (scripts→scripts)

Legal direction only: scripts → src. Never src → scripts (would ModuleNotFoundError in the installed CLI). The CLI (WP03) imports only specify_cli.docs.index_model — never scripts.

Entity: DocsQueryEntry (one per docs/*/.md page)

A new frozen dataclass in src/specify_cli/docs/index_model.py (packaged). Do NOT reuse or widen PageInventoryEntry (C-001), and do NOT place it in scripts/ (unpackaged).

FieldTypeSource / ruleNotes
pathstrrepo-root-relative POSIX path of the pagePrimary key; index sorted by this (NFR-001).
titlestrfrontmatter title → first # H1 → path stemTotal precedence; never empty (path stem is the floor).
divio_typestrfrontmatter type: coerced to DivioTypeReuses DivioType StrEnum (`tutorial\
anchorslist[Anchor]scan body ##/### headingsEmpty list allowed (page with no subheadings).
abstractstrfrontmatter description → first non-heading paragraph → ""Empty allowed (ADR/changelog exemption).

Value object: Anchor

FieldTypeRule
slugstrcanonical slugify(text) + ordinal suffix (-2, -3, …) for duplicates within a page
textstrthe heading's rendered text (stripped of leading #s and surrounding whitespace)
levelint2 or 3 (## / ###)

Invariants

(NFR-001).

  • slug values within a single page are unique (ordinal disambiguation guarantees this).
  • Anchors preserve document order.
  • Slug generation is pure and deterministic → the whole index is byte-stable for an unchanged tree

Generated artifact: docs/development/3-2-docs-retrieval-index.yaml

inventory lockfile convention).

anchors as a list of {slug, text, level} maps.

  • Git-tracked, generated by scripts/docs/docs_index.py, byte-stable.
  • Leading # GENERATED — do not edit by hand; run <generator> to refresh. header comment (mirrors the
  • Top-level: a pages: list of entries sorted by path; each entry serializes the fields above with
  • The page-inventory (3-2-page-inventory.yaml) is a separate file, untouched (C-001).

Schema sketch

# GENERATED — do not edit by hand.
pages:
  - path: docs/architecture/execution-lanes.md
    title: Execution lanes
    divio_type: explanation
    abstract: How Spec Kitty allocates worktrees per computed lane.
    anchors:
      - {slug: overview, text: Overview, level: 2}
      - {slug: lane-allocation, text: Lane allocation, level: 2}

In-memory query model (DocsIndexStore)

Lives in the packaged src/specify_cli/docs/index_model.py (imported by the CLI).

An empty/whitespace term is a validation error (the CLI requires a term argument).

anchor list, so the caller sees which heading matched.

  • Loaded once from the generated file at CLI entry (DocsIndexStore.load(index_path)).
  • Holds the parsed list[DocsQueryEntry].
  • query(term, *, divio_type=None, section=None) -> list[DocsQueryEntry]:
  • term (case-insensitive substring) matches title OR any anchors[].text/slug OR abstract.
  • divio_type (optional) filters to entries whose divio_type equals the given DivioType.
  • section (optional) filters to entries containing an anchor whose slug equals the value.
  • Results preserve index (path-sorted) order.
  • For each matched entry the CLI emits only the matching anchors in the result (FR-003), not the full

No persistence / migration

No database, no state file beyond the generated index, no migration. The index is a build artifact committed to the repo, refreshed by its generator and enforced by its freshness gate.