CANP-WP-0002 T03: composition by reference, two kinds

T01 had already delivered half of composition without naming it: a derived
default binds an input to a prompt dependency, which is transclusion — run
package B, use its output. What was missing was the deterministic half.

`include` inlines another package's rendered template as text. No model is
involved, so the reference CLI can actually perform it, and a shared preamble,
rubric or style block becomes a versioned package instead of copied text. This
is the concrete way to honor INTENT principle 9 without any runtime. `derive`
stays as it was. Both are input defaults, so composition reuses the resolution
machinery rather than adding a second one.

No template inheritance. Four of this repo's own documents argue against it:
INTENT principle 3 (hidden context defeats reuse), section 19's "make package
contents visible before execution", section 17's requirement that behavior
changes produce a new version, and the non-goal on range resolution.

Version selectors: an exact pin is the expected form, with `any`, `newest` and
`>= X.Y.Z` as explicit opt-ins so looseness is written rather than implied by
absence. Selectors are evaluated per dependency against what is available —
no solver, no cross-dependency constraint satisfaction — which is what keeps
them outside the range-resolution non-goal, and the spec says so.

Also defines `type` (template | fragment), which appeared once in the section 4
manifest surface and was specified nowhere.

Spec: 3.2 (type), 5.1 (inclusion resolution rule, renumbered), 6.1 (included
default), 10.1 and 10.2 (new), 18 (rules 14-16), 21.

Reference CLI: validate_version_selector, select_version,
prompt_dependencies replacing prompt_dependency_ids,
check_composition_reference, CatalogComposer with cycle detection, and
resolve_inputs gaining composer= and inherited=. Tests 21 -> 42.

Examples: house-style is a real fragment package; pqrst-estimate composes it
and is bumped 0.1.0 -> 0.2.0 per section 17.

Fixes an ordering bug found while testing: inputs resolved before parameters,
so an included package could not see the including package's parameters and
silently fell back to its own defaults — the fragment rendered tone=neutral
where the including package said blunt. Parameters now resolve first; the
report still lists inputs first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
This commit is contained in:
tegwick 2026-09-06 01:31:58 +02:00
parent 352ed5b30a
commit 4a56f20209
11 changed files with 697 additions and 58 deletions

View file

@ -173,6 +173,21 @@ A short description of the intended purpose. A consumer SHOULD be able to decide
Relative path to the primary prompt template inside the package. The path MUST remain within the package directory.
#### `type`
Declares what kind of artifact the package is. Optional; defaults to
`template`.
| Value | Meaning |
|---|---|
| `template` | A complete prompt, intended to be used on its own |
| `fragment` | A reusable block intended for inclusion in other packages (§ 10.1) |
`type` is advisory. A fragment is a perfectly valid package and MAY be
rendered on its own; the field records the author's intent so that a consumer
can warn when a package is used against it — rendering a fragment as a
standalone prompt, or including a whole template where a fragment was meant.
## 4. Complete v0.1 manifest surface
```yaml
@ -297,22 +312,27 @@ always produce the same output. A consumer that satisfies a derived default
permitted to derive it. A consumer that does not derive uses the default's
static fallback `value` when one is declared, and otherwise leaves the
input unresolved.
6. Resolution MUST report which values were derived, so that a caller can see
what was added on its behalf before the prompt is used.
6. An included input default is satisfied by rendering the included package
(§ 10.2). This is deterministic, so a consumer that can render can satisfy
it; one that cannot locate the package uses the static fallback `value`
when declared, and otherwise leaves the input unresolved.
7. Resolution MUST report which values were derived or included, so that a
caller can see what was added on its behalf before the prompt is used.
**Rendering rules:**
7. Values are substituted as text in v0.1.
8. A placeholder with no resolved value is an error.
9. Template evaluation MUST NOT execute arbitrary code.
10. Rendering MUST NOT derive values. A tool offering derivation MUST perform
8. Values are substituted as text in v0.1.
9. A placeholder with no resolved value is an error.
10. Template evaluation MUST NOT execute arbitrary code.
11. Rendering MUST NOT derive values. A tool offering derivation MUST perform
it as a distinct resolve step whose results are visible to the caller
before rendering.
before rendering. Inclusion likewise happens during resolution; rendering
only substitutes.
A minimal implementation may implement resolution for supplied values and
static defaults only. Such a tool is conforming: it reports an input with an
unsatisfied derived default and no static fallback as unresolved, which
rule 8 makes an error.
rule 9 makes an error.
CPF v0.1 does not define conditionals, loops, filters, or functions. Implementations MAY offer richer rendering modes only when explicitly declared by an extension; they MUST NOT silently reinterpret a v0.1 template as executable code.
@ -426,10 +446,41 @@ the situation this format exists to replace. Validators SHOULD warn when a
**published** package derives inline. Inline derivation is intended for local
and draft packages.
`value` inside a derived default is an optional **static fallback**. Its
#### Included default
A default may instead **include** another package's rendered template as text
(§ 10.2). Unlike a derived default this is deterministic and needs no model,
so every implementation that can render can also include:
```yaml
dependencies:
prompts:
- id: style/house
version: 1.0.0
requirement: required
inputs:
- name: house_style
type: content
required: false
default:
include: style/house
```
`include` names a package declared in `dependencies.prompts`, exactly as a
`derive` reference does. A single default MUST declare at most one of
`include` or `derive`.
An included default MAY also declare a static fallback `value`, used by a
consumer that cannot locate the included package.
#### Static fallback
`value` inside a derived or included default is an optional **static fallback**. Its
meaning is defined by resolution rule 5: a consumer that does not derive uses
it, and an input with neither a derived value nor a fallback stays unresolved,
which rule 8 makes an error. Derivation never silently yields empty content.
The same holds for an inclusion that cannot be resolved.
Declaring a derived default does not oblige any consumer to derive anything,
and does not make the package depend on a particular resolver, model or
@ -534,6 +585,68 @@ Recommended requirement values:
`generate` means that a resolver MAY satisfy a missing dependency by invoking an appropriate generation process. **CPF v0.1 does not define how generation or dependency resolution works.**
### 10.1 Naming and pinning a dependency
A dependency's `id` MAY be a qualified `<registry>:<id>` reference (§ 3.2).
An unqualified id is resolved by the consumer against whatever registries it
draws on, and an id matching packages from more than one registry MUST be
reported as ambiguous rather than chosen.
`version` selects which version satisfies the dependency:
| `version` | Selects |
|---|---|
| a semver literal, e.g. `1.2.0` | exactly that version |
| `any` | any available version; a consumer SHOULD prefer one it already holds |
| `newest` | the newest available version |
| `>= 1.2.0` | the newest available version that is at least `1.2.0` |
**An exact pin is the expected form.** The other three are explicit opt-ins,
visible in the manifest, so that looseness is always something an author wrote
rather than something absence implied.
`version` is REQUIRED for any dependency referenced by a composition or a
derived default (§ 6.1), and RECOMMENDED otherwise.
This is deliberately **not** semantic version range resolution, which remains
a non-goal. There are no unions, no caret or tilde operators, and above all no
solver: each dependency is selected independently against what is available,
and no consumer is expected to satisfy constraints across a dependency graph.
A single lower bound is a selector, not a constraint system.
### 10.2 Composition
A package composes another in one of two ways, both expressed as an input
default (§ 6.1) so that composition reuses the resolution machinery rather
than adding a second one:
| Form | Produces | Deterministic |
|---|---|---|
| `include` | the other package's **rendered template**, inlined as text | yes |
| `derive` | the other package's **result**, obtained by running it | no |
`include` is text composition: a shared preamble, rubric or house-style block
becomes a real versioned package instead of copied text, and inlining it needs
no model. `derive` is output composition: the value is whatever running the
other package produces, and only a consumer able to run it can supply one.
When rendering an included package, its placeholders are resolved from the
including package's already-resolved values by name, falling back to the
included package's own defaults. An included package with a required input
that the including package does not supply is an error naming both packages.
Implementations MUST detect inclusion cycles and report them rather than
recursing.
CPF does **not** define template inheritance. A package does not extend
another, override its sections, or inherit its inputs. Composition is by
reference only, for four reasons drawn from this specification and from
`INTENT.md`: hidden context defeats reuse (INTENT principle 3); § 19 asks that
package contents be visible before execution, which an inheritance chain
prevents; § 17 asks that behavior changes produce a new version, which an
inherited change would bypass; and resolving an override chain is the kind of
graph problem the non-goals exclude.
This allows richer systems to integrate prompt resolution without forcing simple tools to implement an agent runtime.
A prompt dependency declared `requirement: generate` is the mechanism behind a
@ -667,7 +780,13 @@ A v0.1 validator SHOULD verify at least:
11. no input declares both `default` and `required: true`;
12. a derived default declares either a `derive` reference or an inline
`derive.prompt`, never both;
13. a `derive` reference names a package declared in `dependencies.prompts`.
13. a `derive` or `include` reference names a package declared in
`dependencies.prompts`;
14. no default declares both `include` and `derive`;
15. every dependency referenced by a composition or derived default declares a
`version`, and that version is a semver literal, `any`, `newest`, or a
`>=` lower bound (§ 10.1);
16. inclusion does not form a cycle.
A validator SHOULD additionally warn when a package intended for publication
declares an inline derivation prompt (§ 6.1).
@ -837,6 +956,10 @@ rather than resolved by guessing.
`add` takes a package from a path rather than from a registry, so it files the
package under the reserved registry name `local`.
The reference tool satisfies `include` defaults, because inclusion is
deterministic and needs no model, and reports `derive` defaults it cannot
satisfy.
The reference tool never calls a model, so its `resolve` handles supplied
values and static defaults only and reports any input whose derived default it
cannot satisfy. `render` performs the same resolution and then substitutes;

View file

@ -8,6 +8,7 @@ This bundle contains a first project seed for `canned-prompts`:
- [`CannedPromptFormat-v0.1.md`](CannedPromptFormat-v0.1.md) — experimental package-format specification.
- [`reference/`](reference/) — deliberately small Python CLI implementing the basic lifecycle.
- [`examples/pqrst-estimate/`](examples/pqrst-estimate/) — a real package that can be used to exercise the implementation.
- [`examples/house-style/`](examples/house-style/) — a `type: fragment` package that `pqrst-estimate` composes.
## Try the reference implementation
@ -17,7 +18,8 @@ python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# add the included example to your local catalog
# add the included examples to your local catalog
python canned_prompts.py add ../examples/house-style
python canned_prompts.py add ../examples/pqrst-estimate
# find and inspect it
@ -76,6 +78,42 @@ This reference tool never calls a model, so it resolves supplied values and
static defaults only, and reports anything it cannot derive instead of
rendering a prompt with a silent hole in it.
## Composition
A package composes another by declaring it as a dependency and binding it to an
input default. There are two kinds:
- **`include`** — inline the other package's *rendered template* as text.
Deterministic, needs no model, and the reference CLI performs it.
- **`derive`** — use the other package's *result*, obtained by running it.
Only a consumer able to run it can supply one.
`examples/pqrst-estimate` includes `examples/house-style`, so a shared style
block is a versioned package rather than copied text:
```yaml
dependencies:
prompts:
- id: practice/house-style
version: ">= 0.1.0"
requirement: required
inputs:
- name: house_style
required: false
default:
include: practice/house-style
```
A dependency pins an exact version by default; `any`, `newest` and a `>= X.Y.Z`
lower bound are explicit opt-ins. These are per-dependency selectors, not
version ranges — there is no solver, and constraint resolution across a
dependency graph remains a non-goal.
There is no template inheritance. A package never extends another or overrides
its parts; composition is by reference only, so a package's content stays
readable without chasing ancestors.
## Registries and identity
An id names a package *within a registry* (`CannedPromptFormat-v0.1.md`

View file

@ -0,0 +1,27 @@
# practice/house-style
A `type: fragment` package: a reusable block meant to be **included** by other
packages rather than used on its own.
`examples/pqrst-estimate` composes it:
```yaml
dependencies:
prompts:
- id: practice/house-style
version: ">= 0.1.0"
requirement: required
inputs:
- name: house_style
required: false
default:
include: practice/house-style
```
Inclusion is deterministic — the fragment's template is rendered and inlined as
text, with no model involved — so the reference CLI performs it during
`resolve`. See `CannedPromptFormat-v0.1.md` § 10.2.
Its `tone` parameter is inherited from the including package when that package
declares one, and otherwise falls back to the default here.

View file

@ -0,0 +1,5 @@
Write in a {{ tone }} register.
- Lead with the evidence, then the conclusion.
- State uncertainty plainly rather than hedging everything.
- Omit filler, restatement, and praise.

View file

@ -0,0 +1,23 @@
format: canned-prompt/v0.1
id: practice/house-style
name: House Style
version: 0.1.0
summary: >
Shared style block for review and estimate prompts: evidence first,
no filler, state uncertainty plainly.
type: fragment
template: prompt.md
parameters:
tone:
type: enum
values: [neutral, blunt]
default: neutral
description: How direct the wording should be.
output:
format: markdown
description: A style instruction block intended for inclusion, not standalone use.
tags:
- style
- fragment
provenance:
author: canned-prompts seed

View file

@ -1,3 +1,5 @@
{{ house_style }}
Review the coding session described below and produce a **PQRST Estimate** of
where effort was spent.

View file

@ -1,14 +1,27 @@
format: canned-prompt/v0.1
id: practice/pqrst-estimate
name: PQRST Estimate
version: 0.1.0
version: 0.2.0
summary: 'Produce a post-session estimate of effort distributed across the PQRST categories
for an agentic coding session.
'
type: template
template: prompt.md
dependencies:
prompts:
- id: practice/house-style
version: '>= 0.1.0'
requirement: required
inputs:
- name: house_style
type: content
required: false
description: 'Shared house-style block, composed from practice/house-style.
'
default:
include: practice/house-style
- name: session_summary
type: content
required: true

View file

@ -69,6 +69,8 @@ rather than resolved by guessing.
- `add` copies a package directly to catalog.
- `search`, `show`, `resolve`, and `render` operate on catalog packages, and
print qualified `<registry>:<id>` references.
- `include` defaults are satisfied (inclusion is deterministic); `derive`
defaults are reported, not run. Inclusion cycles are detected and named.
- An optional `registry.yaml` names a registry and records namespace claims.
`publish` warns when a namespace is declared `closed` — it cannot
authenticate a publisher, and says so rather than implying it checked.

View file

@ -97,37 +97,65 @@ def declared_names(manifest: dict[str, Any]) -> set[str]:
def input_default_kind(item: dict[str, Any]) -> str:
"""Classify an input's default as 'none', 'static', or 'derived' (§ 6.1)."""
"""Classify a default as 'none', 'static', 'derived', or 'included' (§ 6.1)."""
if "default" not in item:
return "none"
default = item["default"]
if isinstance(default, dict) and "derive" in default:
return "derived"
if isinstance(default, dict):
if "derive" in default and "include" in default:
return "conflict"
if "derive" in default:
return "derived"
if "include" in default:
return "included"
return "static"
def prompt_dependency_ids(manifest: dict[str, Any]) -> set[str]:
def prompt_dependencies(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
dependencies = manifest.get("dependencies") or {}
if not isinstance(dependencies, dict):
raise CannedPromptError("dependencies must be a mapping")
prompts = dependencies.get("prompts") or []
if not isinstance(prompts, list):
raise CannedPromptError("dependencies.prompts must be a list")
ids: set[str] = set()
declared: dict[str, dict[str, Any]] = {}
for entry in prompts:
if isinstance(entry, str):
ids.add(entry)
declared[entry] = {"id": entry}
elif isinstance(entry, dict) and isinstance(entry.get("id"), str):
ids.add(entry["id"])
if "version" in entry:
validate_version_selector(
entry["version"], f"dependency {entry['id']!r}"
)
declared[entry["id"]] = entry
else:
raise CannedPromptError(
"each prompt dependency must be an id string or a mapping with a string id"
)
return ids
return declared
def validate_input_default(item: dict[str, Any], dependency_ids: set[str]) -> None:
"""Validation rules 11-13 of § 18."""
def check_composition_reference(
name: str, reference: str, field: str, declared: dict[str, dict[str, Any]]
) -> None:
if not reference.strip():
raise CannedPromptError(f"input {name!r}: {field} reference is empty")
if reference not in declared:
raise CannedPromptError(
f"input {name!r} {field}s {reference!r}, which is not declared "
"in dependencies.prompts"
)
if "version" not in declared[reference]:
raise CannedPromptError(
f"input {name!r} {field}s {reference!r}, so that dependency must "
"declare a version (§ 10.1)"
)
def validate_input_default(
item: dict[str, Any], declared: dict[str, dict[str, Any]]
) -> None:
"""Validation rules 11-16 of § 18."""
kind = input_default_kind(item)
if kind == "none":
return
@ -139,16 +167,23 @@ def validate_input_default(item: dict[str, Any], dependency_ids: set[str]) -> No
)
if kind == "static":
return
if kind == "conflict":
raise CannedPromptError(
f"input {name!r}: a default declares at most one of 'include' or 'derive'"
)
if kind == "included":
include = item["default"]["include"]
if not isinstance(include, str):
raise CannedPromptError(
f"input {name!r}: include must name a declared prompt dependency"
)
check_composition_reference(name, include, "include", declared)
return
derive = item["default"]["derive"]
if isinstance(derive, str):
if not derive.strip():
raise CannedPromptError(f"input {name!r}: derive reference is empty")
if derive not in dependency_ids:
raise CannedPromptError(
f"input {name!r} derives from {derive!r}, which is not declared "
"in dependencies.prompts"
)
check_composition_reference(name, derive, "derive", declared)
return
if isinstance(derive, dict):
prompt = derive.get("prompt")
@ -204,9 +239,9 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
names = declared_names(manifest)
dependency_ids = prompt_dependency_ids(manifest)
declared = prompt_dependencies(manifest)
for item in manifest.get("inputs") or []:
validate_input_default(item, dependency_ids)
validate_input_default(item, declared)
template = template_path.read_text(encoding="utf-8")
placeholders = set(PLACEHOLDER_RE.findall(template))
@ -302,6 +337,45 @@ def catalog_package_path(catalog: Path, registry: str, package_id: str, version:
return id_path(catalog / check_registry_name(registry), package_id) / version
def validate_version_selector(value: Any, where: str) -> str:
"""A semver literal, `any`, `newest`, or a `>=` lower bound (§ 10.1)."""
if not isinstance(value, str) or not value.strip():
raise CannedPromptError(f"{where}: version must be a non-empty string")
selector = value.strip()
if selector in ("any", "newest"):
return selector
compact = selector.replace(" ", "")
if compact.startswith(">="):
if not SEMVER_RE.match(compact[2:]):
raise CannedPromptError(f"{where}: {value!r} is not a valid lower bound")
return compact
if not SEMVER_RE.match(selector):
raise CannedPromptError(
f"{where}: unsupported version selector {value!r}; expected a semver "
"literal, 'any', 'newest', or '>= X.Y.Z'"
)
return selector
def select_version(available: list[str], selector: str | None) -> str | None:
"""Pick a version from `available` (newest first) per a § 10.1 selector.
Each dependency is selected independently against what is present. There is
no constraint solving across a dependency graph; that stays a non-goal.
"""
if not available:
return None
if selector is None or selector in ("any", "newest"):
return available[0]
if selector.startswith(">="):
floor = parse_semver(selector[2:])
for version in available:
if parse_semver(version) >= floor:
return version
return None
return selector if selector in available else None
def parse_semver(value: str) -> tuple[int, int, int, str]:
match = SEMVER_RE.match(value)
if not match:
@ -545,15 +619,59 @@ class Resolution:
underivable: list[str] = field(default_factory=list)
def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Resolution:
def resolve_inputs(
manifest: dict[str, Any],
raw_values: dict[str, str],
composer: "Composer | None" = None,
inherited: dict[str, Any] | None = None,
) -> Resolution:
"""Resolve inputs and parameters per § 5.1.
This tool never calls a model, so a derived default is satisfied only by its
static fallback `value`. An input with a derived default and no fallback is
reported as underivable rather than silently rendered as empty.
`composer`, when supplied, satisfies `include` defaults by rendering the
included package (§ 10.2). Inclusion is deterministic, so a tool that can
render can satisfy it; derivation is not, and this tool never calls a model,
so a derived default is satisfied only by its static fallback `value`.
`inherited` carries the including package's already-resolved values into an
included package (§ 10.2). Those values are used as-is rather than coerced,
and do not count as caller-supplied.
"""
result = Resolution()
inherited = inherited or {}
known: set[str] = set()
declared = prompt_dependencies(manifest)
def compose(name: str, item: dict[str, Any]) -> None:
default = item["default"]
reference = default["include"]
if composer is not None:
entry = declared.get(reference) or {}
result.values[name] = composer(reference, entry.get("version"), result.values)
result.origins[name] = f"included from {reference}"
return
if "value" in default:
result.values[name] = default["value"]
result.origins[name] = "fallback (not included)"
else:
result.origins[name] = "unresolved (included, no fallback)"
result.underivable.append(name)
# Parameters resolve first: a composed input inherits the including
# package's resolved values (§ 10.2), so those must already be settled.
parameters = manifest.get("parameters") or {}
for name, spec in parameters.items():
known.add(name)
if name in raw_values:
result.values[name] = coerce_value(raw_values[name], spec)
result.origins[name] = "supplied"
elif name in inherited:
result.values[name] = inherited[name]
result.origins[name] = "inherited"
elif "default" in spec:
result.values[name] = spec["default"]
result.origins[name] = "default"
else:
result.origins[name] = "unresolved (no default)"
inputs = manifest.get("inputs") or []
for item in inputs:
@ -563,6 +681,10 @@ def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Reso
result.values[name] = raw_values[name]
result.origins[name] = "supplied"
continue
if name in inherited:
result.values[name] = inherited[name]
result.origins[name] = "inherited"
continue
if item.get("required", False):
raise CannedPromptError(f"missing required input: {name}")
@ -570,6 +692,8 @@ def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Reso
if kind == "static":
result.values[name] = item["default"]
result.origins[name] = "default"
elif kind == "included":
compose(name, item)
elif kind == "derived":
default = item["default"]
if "value" in default:
@ -581,17 +705,10 @@ def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Reso
else:
result.origins[name] = "unresolved (optional, no default)"
parameters = manifest.get("parameters") or {}
for name, spec in parameters.items():
known.add(name)
if name in raw_values:
result.values[name] = coerce_value(raw_values[name], spec)
result.origins[name] = "supplied"
elif "default" in spec:
result.values[name] = spec["default"]
result.origins[name] = "default"
else:
result.origins[name] = "unresolved (no default)"
# Report inputs before parameters regardless of resolution order.
ordered = {item["name"]: result.origins[item["name"]] for item in inputs}
ordered.update({name: result.origins[name] for name in parameters})
result.origins = ordered
unknown = sorted(set(raw_values) - known)
if unknown:
@ -604,6 +721,79 @@ def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict
return resolve_inputs(manifest, raw_values).values
class CatalogComposer:
"""Satisfies `include` defaults from the catalog (§ 10.2).
Inclusion is text composition: the included package is rendered and its text
is inlined. No model is involved, so this is deterministic.
"""
def __init__(self, catalog: Path) -> None:
self.catalog = catalog
self._stack: list[str] = []
def __call__(
self, reference: str, selector: str | None, outer_values: dict[str, Any]
) -> str:
if reference in self._stack:
raise CannedPromptError(
"inclusion cycle: " + " -> ".join([*self._stack, reference])
)
registry, package_id = parse_reference(reference)
names = (
[registry]
if registry is not None
else [
name
for name in catalog_registries(self.catalog)
if versions_at(id_path(self.catalog / name, package_id))
]
)
if len(names) > 1:
raise CannedPromptError(
f"included package {package_id} is present in more than one registry: "
+ ", ".join(f"{name}:{package_id}" for name in names)
+ " — qualify the dependency id"
)
if not names:
raise CannedPromptError(f"included package not found: {reference}")
base = id_path(self.catalog / names[0], package_id)
version = select_version(versions_at(base), selector)
if version is None:
raise CannedPromptError(
f"included package {reference}: no version satisfies {selector!r}"
)
package_dir = base / version
manifest = validate_package(package_dir)
template_path = safe_relative_file(package_dir, manifest["template"], "template")
self._stack.append(reference)
try:
inner = resolve_inputs(manifest, {}, composer=self, inherited=outer_values)
except CannedPromptError as exc:
if str(exc).startswith("inclusion cycle:"):
raise
raise CannedPromptError(f"while including {reference}: {exc}") from exc
finally:
self._stack.pop()
text = template_path.read_text(encoding="utf-8")
used = set(PLACEHOLDER_RE.findall(text))
missing = sorted(used - set(inner.values))
if missing:
raise CannedPromptError(
f"included package {reference} has unresolved inputs the including "
"package does not supply: " + ", ".join(missing)
)
return render_template(text, inner.values)
Composer = CatalogComposer
def render_template(template: str, values: dict[str, Any]) -> str:
def replace(match: re.Match[str]) -> str:
name = match.group(1)
@ -625,7 +815,7 @@ def cmd_render(args: argparse.Namespace) -> None:
manifest = validate_package(package_dir)
template_path = safe_relative_file(package_dir, manifest["template"], "template")
raw = supplied_values(args.set_values)
resolution = resolve_inputs(manifest, raw)
resolution = resolve_inputs(manifest, raw, composer=CatalogComposer(catalog))
template = template_path.read_text(encoding="utf-8")
used = set(PLACEHOLDER_RE.findall(template))
@ -647,7 +837,7 @@ def cmd_resolve(args: argparse.Namespace) -> None:
package_dir, _ = resolve_installed(catalog, args.id, args.version)
manifest = validate_package(package_dir)
raw = supplied_values(args.set_values)
resolution = resolve_inputs(manifest, raw)
resolution = resolve_inputs(manifest, raw, composer=CatalogComposer(catalog))
if args.json:
print(
@ -664,12 +854,13 @@ def cmd_resolve(args: argparse.Namespace) -> None:
return
width = max((len(name) for name in resolution.origins), default=0)
origin_width = max((len(o) for o in resolution.origins.values()), default=0)
for name, origin in resolution.origins.items():
if name in resolution.values:
preview = str(resolution.values[name]).replace("\n", " ")
if len(preview) > 48:
preview = preview[:45] + "..."
print(f"{name:<{width}} {origin:<32} {preview}")
print(f"{name:<{width}} {origin:<{origin_width}} {preview}")
else:
print(f"{name:<{width}} {origin}")
if resolution.underivable:

View file

@ -336,3 +336,171 @@ def test_legacy_catalog_layout_is_reported(tmp_path: Path) -> None:
(legacy / "prompt.md").write_text("{{ greeting }}\n", encoding="utf-8")
with pytest.raises(cp.CannedPromptError, match="pre-registry-scoped"):
cp.resolve_installed(catalog, "practice/thing", None)
# --- version selectors (§ 10.1) ---
AVAILABLE = ["2.1.0", "2.0.0", "1.5.0", "1.0.0"]
@pytest.mark.parametrize(
"available,selector,expected",
[
(AVAILABLE, None, "2.1.0"),
(AVAILABLE, "any", "2.1.0"),
(AVAILABLE, "newest", "2.1.0"),
(AVAILABLE, "1.5.0", "1.5.0"),
(AVAILABLE, ">=2.0.0", "2.1.0"),
(AVAILABLE, "9.9.9", None),
(AVAILABLE, ">=9.9.9", None),
(["1.5.0", "1.0.0"], ">=1.2.0", "1.5.0"),
([], "newest", None),
],
)
def test_select_version(available, selector, expected) -> None:
assert cp.select_version(available, selector) == expected
@pytest.mark.parametrize("bad", ["^1.0.0", "~1.2", "1.x", ">=nope", "", "latest"])
def test_range_syntax_is_rejected(bad) -> None:
with pytest.raises(cp.CannedPromptError):
cp.validate_version_selector(bad, "dep")
# --- composition (§ 10.2) ---
FRAGMENT = """\
format: canned-prompt/v0.1
id: style/house
name: House Style
version: 1.0.0
summary: Shared style block.
type: fragment
template: prompt.md
parameters:
tone:
type: enum
values: [neutral, blunt]
default: neutral
"""
COMPOSER = """\
format: canned-prompt/v0.1
id: review/change
name: Change Review
version: 1.0.0
summary: Composes the shared style block.
template: prompt.md
dependencies:
prompts:
- id: style/house
version: 1.0.0
inputs:
- name: house_style
required: false
default:
include: style/house
parameters:
tone:
type: enum
values: [neutral, blunt]
default: blunt
"""
def place(catalog: Path, registry: str, package_id: str, version: str,
manifest: str, template: str) -> None:
dst = cp.catalog_package_path(catalog, registry, package_id, version)
dst.mkdir(parents=True)
(dst / "prompt.yaml").write_text(manifest, encoding="utf-8")
(dst / "prompt.md").write_text(template, encoding="utf-8")
@pytest.fixture()
def composed(tmp_path: Path) -> Path:
catalog = tmp_path / "catalog"
place(catalog, "local", "style/house", "1.0.0", FRAGMENT, "Tone is {{ tone }}.")
place(catalog, "local", "review/change", "1.0.0", COMPOSER, "S: {{ house_style }}")
return catalog
def test_include_inlines_rendered_template(composed: Path) -> None:
package_dir, _ = cp.resolve_installed(composed, "review/change", None)
manifest = cp.validate_package(package_dir)
resolution = cp.resolve_inputs(manifest, {}, composer=cp.CatalogComposer(composed))
assert resolution.values["house_style"] == "Tone is blunt."
assert resolution.origins["house_style"] == "included from style/house"
def test_include_inherits_outer_parameters(composed: Path) -> None:
"""The fragment's own default is neutral; the including package says blunt."""
package_dir, _ = cp.resolve_installed(composed, "review/change", None)
manifest = cp.validate_package(package_dir)
resolution = cp.resolve_inputs(
manifest, {"tone": "neutral"}, composer=cp.CatalogComposer(composed)
)
assert resolution.values["house_style"] == "Tone is neutral."
def test_include_without_composer_falls_back_to_unresolved(composed: Path) -> None:
package_dir, _ = cp.resolve_installed(composed, "review/change", None)
manifest = cp.validate_package(package_dir)
resolution = cp.resolve_inputs(manifest, {})
assert resolution.underivable == ["house_style"]
def test_inclusion_cycle_is_detected(tmp_path: Path) -> None:
catalog = tmp_path / "catalog"
for this, other in (("cyc/a", "cyc/b"), ("cyc/b", "cyc/a")):
manifest = (
"format: canned-prompt/v0.1\n"
f"id: {this}\nname: X\nversion: 1.0.0\nsummary: s\ntemplate: prompt.md\n"
f"dependencies:\n prompts:\n - id: {other}\n version: 1.0.0\n"
f"inputs:\n - name: other\n required: false\n"
f" default:\n include: {other}\n"
)
place(catalog, "local", this, "1.0.0", manifest, "{{ other }}")
package_dir, _ = cp.resolve_installed(catalog, "cyc/a", None)
manifest = cp.validate_package(package_dir)
with pytest.raises(cp.CannedPromptError, match="inclusion cycle"):
cp.resolve_inputs(manifest, {}, composer=cp.CatalogComposer(catalog))
def test_include_and_derive_together_is_rejected(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
dependencies:
prompts:
- id: style/house
version: 1.0.0
inputs:
- name: greeting
required: false
default:
include: style/house
derive: style/house
""",
)
with pytest.raises(cp.CannedPromptError, match="at most one of"):
cp.validate_package(pkg)
def test_composed_dependency_must_declare_a_version(tmp_path: Path) -> None:
pkg = write_pkg(
tmp_path / "p",
BASE
+ """\
dependencies:
prompts:
- id: style/house
inputs:
- name: greeting
required: false
default:
include: style/house
""",
)
with pytest.raises(cp.CannedPromptError, match="must declare a version"):
cp.validate_package(pkg)

View file

@ -179,7 +179,7 @@ non-goals and were not touched.
```task
id: CANP-WP-0002-T03
status: todo
status: done
priority: high
state_hub_task_id: "0505c790-e57e-568c-9c8c-376957c9b08e"
```
@ -189,15 +189,62 @@ capture") and the `dependencies.prompts` field both promise composition, but
v0.1 defines no mechanism — `dependencies` is a declared field with no
semantics.
T01 has since settled one corner of this: a derived default binds an input to
a prompt dependency declared `requirement: generate`, so package-to-package
reference already exists for that one case. Build on it rather than around it.
**Found during the work.** T01 had already delivered half of composition
without it being named as such: a derived default binds an input to a prompt
dependency, which is transclusion — run package B, use its output. What was
missing was the *deterministic* half. Separately, `type: template` appeared
once in the § 4 manifest surface and was defined nowhere in the specification.
Decide what composition means at the *artifact* level: how one package
references another, whether references are includes, extends, or plain
declared prerequisites, and how versions are pinned. Leaning: declaration only
— a package states what it needs, and resolution stays with the consumer, per
the INTENT boundary. Do not introduce range resolution (INTENT non-goal).
**Decisions (operator, 2026-09-06):**
- *No inheritance.* A package never extends another, overrides its sections,
or inherits its inputs. Four of this repo's own documents argue against it:
INTENT principle 3 (hidden context defeats reuse); § 19's "make package
contents visible before execution", which an inheritance chain prevents;
§ 17's requirement that behavior changes produce a new version, which an
inherited change bypasses; and the non-goal on range resolution, which an
override chain would drag in.
- *Two composition kinds.* `include` inlines another package's **rendered
template** as text — deterministic, no model, and the reference CLI performs
it. `derive` (T01) uses another package's **result** — non-deterministic and
only available to a consumer that can run it. Both are expressed as input
defaults, so composition reuses the resolution machinery instead of adding a
second one.
- *Version selectors.* An exact pin is the expected form; `any`, `newest` and
`>= X.Y.Z` are explicit opt-ins, so looseness is always written rather than
implied by absence. These are per-dependency selectors evaluated
independently against what is available. There is no solver and no
cross-dependency constraint satisfaction, which is the line that keeps this
outside the range-resolution non-goal — the spec says so explicitly.
Delivered:
1. § 3.2 defines `type` (`template` | `fragment`), closing the undefined-field
gap. It is advisory: a fragment is a valid package and may be rendered
alone; the field records intent so a consumer can warn.
2. § 10.1 (new) covers naming and pinning: qualified dependency ids, the four
version selectors, `version` required for anything composed, and an
explicit statement of why this is not range resolution.
3. § 10.2 (new) defines both composition kinds, parameter pass-through into an
included package, mandatory cycle detection, and the reasoned refusal of
inheritance.
4. § 6.1 gains the included-default form; § 5.1 gains resolution rule 6 for
inclusion (rules renumbered); § 18 gains rules 1416; § 21 notes that the
reference tool satisfies inclusions and reports derivations.
5. `reference/canned_prompts.py`: `validate_version_selector`,
`select_version`, `prompt_dependencies` (replacing
`prompt_dependency_ids`), `check_composition_reference`, `CatalogComposer`
with cycle detection, and `resolve_inputs(composer=, inherited=)`.
Tests 21 → 42.
6. `examples/house-style/` (new) is a real `type: fragment` package, and
`examples/pqrst-estimate` composes it — bumped 0.1.0 → 0.2.0 per § 17,
since including a style block changes intended behavior.
**Ordering bug found and fixed during the work.** Inputs were resolved before
parameters, so an included package could not see the including package's
parameters and silently fell back to its own defaults — the fragment rendered
`tone: neutral` where the including package said `blunt`. Parameters now
resolve first; the report still lists inputs before parameters.
## Canonical eval schemas