Skip to main content

Hierarchical prompt resolver CLI

Project description

stemmata

CI PyPI Python Version License

Prompt reuse across repositories is a mess. You copy a YAML prompt into a new project, tweak it, and within a week the original and the copy have diverged. Multiply that by a dozen services and you're maintaining the same boilerplate in twenty places.

stemmata fixes this with hierarchical composition: prompts declare ancestors (by relative path or by registry coordinate), and the CLI resolves the full inheritance chain into a single, deterministic YAML document. Ancestor prompts are distributed as npm packages through any private registry you already run.

Table of Contents

Features

  • Hierarchical composition: prompts declare ancestors as paths or (package, version, prompt) coordinates; the full transitive closure is resolved eagerly via breadth-first search.
  • Deterministic merging: nearest-wins for scalars and lists, deep-merge for maps, with breadth-first search distance plus reference occurring-ordering (for breaking ties) so the output is reproducible.
  • Placeholder interpolation: ${path} references resolve against the merged namespace, with structural, textual, and list-splat forms.
  • Markdown resource embedding: packages may ship Markdown payloads alongside prompts and splice them in as opaque text via ${resource:...}, resolved eagerly on the same cache and registry rails as prompts.
  • npm registry transport: speaks the standard npm REST API; credentials read from ~/.npmrc.

Installation

pip install stemmata

Requires Python 3.12+ (for tarfile.data_filter). Third-party dependencies: PyYAML and jsonschema.

Quick Start

# You have a local prompt that inherits from a base — resolve it:
stemmata resolve ./prompts/onboarding.yaml

# Or resolve a prompt published to your registry by coordinate:
stemmata resolve '@acme/prompts-core@1.2.3#onboarding'

# Describe every prompt in a published package (or a single one):
stemmata describe '@acme/prompts-core@1.2.3'
stemmata describe '@acme/prompts-core@1.2.3#onboarding'

# Print the ancestor DAG as an ASCII tree:
stemmata tree ./prompts/onboarding.yaml
stemmata tree '@acme/prompts-core@1.2.3#onboarding'

# Need machine-readable output for a script or pipeline:
stemmata --output json resolve ./prompts/onboarding.yaml

# Validate a prompt (or an entire directory) against its $schema:
stemmata validate ./prompts/onboarding.yaml
stemmata validate ./prompts/

# Wipe the local cache (by default stored under ~/.cache/stemmata):
stemmata cache clear

# Scaffold a package.json for an existing directory of prompts and resources:
stemmata init ./my-package

# Install a local package into the cache so it can be resolved offline by coordinate:
stemmata install ./my-package

CLI Reference

stemmata [GLOBAL FLAGS] <subcommand> [ARGS]

Global flags

Flag Default Description
--output {yaml,json,text} yaml (text for tree) Output format.
--verbose off Timestamped diagnostics on stderr.
--offline off Forbid network access; exit 22 if a fetch would be needed.
--refresh off Re-fetch artifacts even if cached.
--version Print version and exit.

resolve <target>

Resolves a single prompt. Target is either a local path (./prompts/onboarding.yaml) or a registry coordinate (@<scope>/<name>@<version>#<prompt-id>).

Resource limits: --max-prompts (default 1000), --max-depth (default 50), --http-timeout (default 30s), --timeout (default 5m).

On success, stdout carries the resolved YAML (or a JSON envelope with {root, content, ancestors[]}). On failure, stdout carries a JSON error envelope regardless of --output, and stderr gets a one-line human-readable summary.

publish [path]

Builds and uploads the package at path (default .) to the registry routed by ~/.npmrc. Before any bytes leave the machine, every prompt listed in package.json is checked for: (1) ancestor cycles, (2) intra-document type conflicts, (3) placeholder resolvability against the fully resolved namespace, (4) dependencies consistency with the cross-package references found in the prompts (including those inside ${resource:...} placeholders), (5) manifest closure under relative-path references — every local ancestors entry must resolve to a path that is itself declared in prompts, since only manifest-listed files are bundled, (6) $schema validation against the prompt's content contract, and (7) resource-graph integrity — every ${resource:...} occupies an allowed position, every local resource reference resolves to an entry in the resources array, and the Markdown-embedding graph contains no cycles. All errors discovered in the pass are aggregated into a single envelope; the headline exit code is the most severe one (cycle > schema > reference > merge > placeholder).

Flags: --dry-run (build the tarball but skip upload), --tarball <path> (write the built tarball to path). The tarball is deterministic: identical inputs produce byte-identical output.

$schema enforcement is always on. An unfetchable $schema URI (missing local file, offline with no cache, or network failure) aborts with exit code 10.

validate <target>

Validates prompt files against their $schema. Target is a file path or a directory (recursively discovers .yaml, .yml, .json files). For YAML prompts with ancestors, the full resolve → merge → interpolate pipeline runs before validation so inherited and interpolated values participate.

Multi-document YAML files (separated by ---) are supported — each sub-document is validated independently against its own $schema. Files without $schema are silently skipped.

All violations are collected and reported together. Error payloads include the natural source line number of the offending value.

Flags: the same resource-limit flags as resolve. An unfetchable $schema URI (missing file, offline with no cache, or network failure) aborts with exit code 10.

$schema enforcement supports file://, http://, and https:// URIs, as well as bare relative paths (resolved against the validated file's directory).

describe <target>

Resolves every prompt in a published package, or a single prompt inside it. Target is either @<scope>/<name>@<version> (describe the whole package) or @<scope>/<name>@<version>#<prompt-id> (describe one prompt). Each prompt is resolved with ancestors merged and placeholders interpolated, using the same pipeline as resolve.

Default YAML output emits one document per prompt, separated by --- start markers. Each sub-document is prefixed with a # <canonical-id> comment (e.g. # @acme/prompts-core@1.2.3#onboarding) so the reader can tell which prompt is which. --output json returns an array of {root, content, ancestors[]} entries in manifest declaration order (length 1 when targeting a single prompt). Package artifacts are fetched through the usual cache (~/.cache/stemmata by default), so repeated invocations reuse downloaded tarballs; --offline and --refresh behave as with resolve.

Resource-limit flags match resolve.

tree <target>

Prints the ancestor DAG rooted at <target>, which takes the same two forms as resolve (a local YAML/JSON path or a @<scope>/<name>@<version>#<prompt-id> coordinate). The resolver runs the same eager pipeline as resolve, so cycles, missing ancestors, and version conflicts surface with the usual exit codes; --offline / --refresh and the resource-limit flags all apply.

Default --output text produces an ASCII tree (|-- / `-- connectors). Markdown resources reached via ${resource:...} are rendered inline under the prompt (or resource) that references them and are prefixed with resource: to disambiguate them from prompt coordinates. Diamond inheritance — across both ancestor and resource edges — is rendered once in full and subsequent visits are marked (seen) so the output stays finite:

root.yaml
|-- a.yaml
|   `-- x.yaml
|-- b.yaml
|   `-- x.yaml  (seen)
`-- resource:@acme/prompts-core@1.2.3#playbook
    `-- resource:@acme/prompts-core@1.2.3#safety

--output yaml / --output json emit a {root, nodes[], edges[]} envelope instead, with each node carrying its canonical id, source file, BFS distance from the root, and kind (prompt or resource). Edges carry kind (ancestor or resource).

init [path]

Scaffolds (or updates) a package.json at path (default .). Scans ./prompts recursively for .yaml, .yml, and .json files and ./resources recursively for .md files, deriving each entry's id from the basename and setting contentType from the extension. Entries are sorted alphabetically by path and rendered one-per-line.

install [path]

Installs the package at path (default .) into the local cache.

cache clear

Evicts every cached entry.

Prompt Format

A prompt is a structured mapping (YAML or JSON) with reserved envelope keys plus arbitrary content:

ancestors:
  - ../base.yaml                         # relative path (within package)
  - package: "@acme/common"              # cross-package coordinate
    version: "1.0.4"
    prompt: "defaults"
$schema: "https://schemas.example/foo.v1.json"   # optional, enforced at publish time if present

database:
  host: "db.internal"
  port: 5432
body: |
  Region is ${vars.region}; DB is ${database.host}:${database.port}.

ancestors and $schema are stripped from the namespace; every other key is addressable via dotted path.

Package manifest (package.json)

{
  "name": "@acme/prompts-core",
  "version": "1.2.3",
  "license": "UNLICENSED",
  "dependencies": { "@acme/common": "1.0.4" },
  "prompts": [
    { "id": "base",       "path": "prompts/base.yaml",             "contentType": "yaml" },
    { "id": "onboarding", "path": "prompts/extra/onboarding.yaml", "contentType": "yaml" }
  ],
  "resources": [
    { "id": "overview", "path": "resources/overview.md", "contentType": "markdown" }
  ]
}

name must be @<scope>/<n>. version is strict SemVer, no ranges. prompts is non-empty; id defaults to basename without extension and must match [a-z0-9][a-z0-9_-]*. resources is optional; ids, paths, and case-folded path uniqueness are shared across the union of prompts and resources.

Markdown resources

Prompts may embed opaque Markdown payloads via ${resource:<POSIX-relative-path>} (same-package) or ${resource:@<scope>/<name>@<version>#<id>} (coordinate). The reference must stand alone — either as the sole content of a line inside a block scalar or a Markdown file, or as the entire trimmed text of a flow-style YAML scalar or JSON string. Resource payloads are substituted verbatim after ancestor-namespace interpolation; they do not contribute keys to the merged namespace and any ${...} sequences inside them are left literal.

Merge Semantics

Reachable prompts are layered by breadth-first search distance from the root (distance 0 = root, wins everything). Ties at the same distance break by enqueue order.

Maps are deep-merged, with the nearer value winning at each leaf:

# ancestor (distance 1)           # root (distance 0)
database:                         database:
  host: "base.internal"             host: "override.internal"
  port: 5432                        ssl: true

Resolved: database.host = "override.internal" (nearer wins), database.port = 5432 (survives from ancestor), database.ssl = true (only root provides it).

Lists replace wholesale — no element-level merge. null at a nearer layer shadows the entire subtree beneath it.

For the full interpolation reference (structural vs. textual placeholders, list splat, non-splat ${=...} form, escaping, version conflict resolution), see docs/interpolation.md.

Exit Codes

Code Meaning
0 Success
1 Generic / unexpected failure
2 Usage error
10 Schema validation error
11 Unknown ancestor, prompt, or resource reference
12 Cycle detected (ancestor or resource graph)
14 Unresolvable placeholder
15 Merge / interpolation type mismatch
20 Network / registry error
21 Cache error
22 Offline-mode violation

On failure, stdout always carries a JSON error envelope with {status, exit_code, command, error: {code, category, message, ...}} regardless of --output. Stderr gets a single-line human summary.

Configuration

Registry routing and credentials come from ~/.npmrc for both fetch and publish.

Testing

PYTHONPATH=src python -m pytest tests/ -q

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

stemmata-0.0.2.tar.gz (95.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stemmata-0.0.2-py3-none-any.whl (67.8 kB view details)

Uploaded Python 3

File details

Details for the file stemmata-0.0.2.tar.gz.

File metadata

  • Download URL: stemmata-0.0.2.tar.gz
  • Upload date:
  • Size: 95.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for stemmata-0.0.2.tar.gz
Algorithm Hash digest
SHA256 61b4e0955a55d2f6381a01d8f4ff83489f297584f42bea7b316e5421207f25a8
MD5 53dbbfb5aa7ba072d15f8a04a5889231
BLAKE2b-256 b74113c63d3a743c6a045293fbf847553bf56322f402e82bac12d43ada2ee535

See more details on using hashes here.

Provenance

The following attestation bundles were made for stemmata-0.0.2.tar.gz:

Publisher: publish.yml on pjmartos/stemmata

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file stemmata-0.0.2-py3-none-any.whl.

File metadata

  • Download URL: stemmata-0.0.2-py3-none-any.whl
  • Upload date:
  • Size: 67.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for stemmata-0.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 9b7b568d42c5730980b261cb899835cc29a172ee88d709787f627a9f343b038b
MD5 b2175ddc4fac16f66444288965f50dff
BLAKE2b-256 0a9c10d437aa648018e8371387ffebb91e737065382afbeb5a0a5f7bdcb9282e

See more details on using hashes here.

Provenance

The following attestation bundles were made for stemmata-0.0.2-py3-none-any.whl:

Publisher: publish.yml on pjmartos/stemmata

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page