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:
parent
352ed5b30a
commit
4a56f20209
11 changed files with 697 additions and 58 deletions
|
|
@ -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.
|
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
|
## 4. Complete v0.1 manifest surface
|
||||||
|
|
||||||
```yaml
|
```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
|
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
|
static fallback `value` when one is declared, and otherwise leaves the
|
||||||
input unresolved.
|
input unresolved.
|
||||||
6. Resolution MUST report which values were derived, so that a caller can see
|
6. An included input default is satisfied by rendering the included package
|
||||||
what was added on its behalf before the prompt is used.
|
(§ 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:**
|
**Rendering rules:**
|
||||||
|
|
||||||
7. Values are substituted as text in v0.1.
|
8. Values are substituted as text in v0.1.
|
||||||
8. A placeholder with no resolved value is an error.
|
9. A placeholder with no resolved value is an error.
|
||||||
9. Template evaluation MUST NOT execute arbitrary code.
|
10. Template evaluation MUST NOT execute arbitrary code.
|
||||||
10. Rendering MUST NOT derive values. A tool offering derivation MUST perform
|
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
|
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
|
A minimal implementation may implement resolution for supplied values and
|
||||||
static defaults only. Such a tool is conforming: it reports an input with an
|
static defaults only. Such a tool is conforming: it reports an input with an
|
||||||
unsatisfied derived default and no static fallback as unresolved, which
|
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.
|
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
|
**published** package derives inline. Inline derivation is intended for local
|
||||||
and draft packages.
|
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
|
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,
|
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.
|
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,
|
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
|
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.**
|
`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.
|
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
|
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`;
|
11. no input declares both `default` and `required: true`;
|
||||||
12. a derived default declares either a `derive` reference or an inline
|
12. a derived default declares either a `derive` reference or an inline
|
||||||
`derive.prompt`, never both;
|
`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
|
A validator SHOULD additionally warn when a package intended for publication
|
||||||
declares an inline derivation prompt (§ 6.1).
|
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
|
`add` takes a package from a path rather than from a registry, so it files the
|
||||||
package under the reserved registry name `local`.
|
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
|
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
|
values and static defaults only and reports any input whose derived default it
|
||||||
cannot satisfy. `render` performs the same resolution and then substitutes;
|
cannot satisfy. `render` performs the same resolution and then substitutes;
|
||||||
|
|
|
||||||
40
README.md
40
README.md
|
|
@ -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.
|
- [`CannedPromptFormat-v0.1.md`](CannedPromptFormat-v0.1.md) — experimental package-format specification.
|
||||||
- [`reference/`](reference/) — deliberately small Python CLI implementing the basic lifecycle.
|
- [`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/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
|
## Try the reference implementation
|
||||||
|
|
||||||
|
|
@ -17,7 +18,8 @@ python -m venv .venv
|
||||||
. .venv/bin/activate # Windows: .venv\Scripts\activate
|
. .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
pip install -r requirements.txt
|
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
|
python canned_prompts.py add ../examples/pqrst-estimate
|
||||||
|
|
||||||
# find and inspect it
|
# 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
|
static defaults only, and reports anything it cannot derive instead of
|
||||||
rendering a prompt with a silent hole in it.
|
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
|
## Registries and identity
|
||||||
|
|
||||||
An id names a package *within a registry* (`CannedPromptFormat-v0.1.md`
|
An id names a package *within a registry* (`CannedPromptFormat-v0.1.md`
|
||||||
|
|
|
||||||
27
examples/house-style/README.md
Normal file
27
examples/house-style/README.md
Normal 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.
|
||||||
5
examples/house-style/prompt.md
Normal file
5
examples/house-style/prompt.md
Normal 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.
|
||||||
23
examples/house-style/prompt.yaml
Normal file
23
examples/house-style/prompt.yaml
Normal 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
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
{{ house_style }}
|
||||||
|
|
||||||
Review the coding session described below and produce a **PQRST Estimate** of
|
Review the coding session described below and produce a **PQRST Estimate** of
|
||||||
where effort was spent.
|
where effort was spent.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,27 @@
|
||||||
format: canned-prompt/v0.1
|
format: canned-prompt/v0.1
|
||||||
id: practice/pqrst-estimate
|
id: practice/pqrst-estimate
|
||||||
name: 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
|
summary: 'Produce a post-session estimate of effort distributed across the PQRST categories
|
||||||
for an agentic coding session.
|
for an agentic coding session.
|
||||||
|
|
||||||
'
|
'
|
||||||
type: template
|
type: template
|
||||||
template: prompt.md
|
template: prompt.md
|
||||||
|
dependencies:
|
||||||
|
prompts:
|
||||||
|
- id: practice/house-style
|
||||||
|
version: '>= 0.1.0'
|
||||||
|
requirement: required
|
||||||
inputs:
|
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
|
- name: session_summary
|
||||||
type: content
|
type: content
|
||||||
required: true
|
required: true
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ rather than resolved by guessing.
|
||||||
- `add` copies a package directly to catalog.
|
- `add` copies a package directly to catalog.
|
||||||
- `search`, `show`, `resolve`, and `render` operate on catalog packages, and
|
- `search`, `show`, `resolve`, and `render` operate on catalog packages, and
|
||||||
print qualified `<registry>:<id>` references.
|
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.
|
- An optional `registry.yaml` names a registry and records namespace claims.
|
||||||
`publish` warns when a namespace is declared `closed` — it cannot
|
`publish` warns when a namespace is declared `closed` — it cannot
|
||||||
authenticate a publisher, and says so rather than implying it checked.
|
authenticate a publisher, and says so rather than implying it checked.
|
||||||
|
|
|
||||||
|
|
@ -97,37 +97,65 @@ def declared_names(manifest: dict[str, Any]) -> set[str]:
|
||||||
|
|
||||||
|
|
||||||
def input_default_kind(item: dict[str, Any]) -> 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:
|
if "default" not in item:
|
||||||
return "none"
|
return "none"
|
||||||
default = item["default"]
|
default = item["default"]
|
||||||
if isinstance(default, dict) and "derive" in default:
|
if isinstance(default, dict):
|
||||||
|
if "derive" in default and "include" in default:
|
||||||
|
return "conflict"
|
||||||
|
if "derive" in default:
|
||||||
return "derived"
|
return "derived"
|
||||||
|
if "include" in default:
|
||||||
|
return "included"
|
||||||
return "static"
|
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 {}
|
dependencies = manifest.get("dependencies") or {}
|
||||||
if not isinstance(dependencies, dict):
|
if not isinstance(dependencies, dict):
|
||||||
raise CannedPromptError("dependencies must be a mapping")
|
raise CannedPromptError("dependencies must be a mapping")
|
||||||
prompts = dependencies.get("prompts") or []
|
prompts = dependencies.get("prompts") or []
|
||||||
if not isinstance(prompts, list):
|
if not isinstance(prompts, list):
|
||||||
raise CannedPromptError("dependencies.prompts must be a list")
|
raise CannedPromptError("dependencies.prompts must be a list")
|
||||||
ids: set[str] = set()
|
declared: dict[str, dict[str, Any]] = {}
|
||||||
for entry in prompts:
|
for entry in prompts:
|
||||||
if isinstance(entry, str):
|
if isinstance(entry, str):
|
||||||
ids.add(entry)
|
declared[entry] = {"id": entry}
|
||||||
elif isinstance(entry, dict) and isinstance(entry.get("id"), str):
|
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:
|
else:
|
||||||
raise CannedPromptError(
|
raise CannedPromptError(
|
||||||
"each prompt dependency must be an id string or a mapping with a string id"
|
"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:
|
def check_composition_reference(
|
||||||
"""Validation rules 11-13 of § 18."""
|
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)
|
kind = input_default_kind(item)
|
||||||
if kind == "none":
|
if kind == "none":
|
||||||
return
|
return
|
||||||
|
|
@ -139,16 +167,23 @@ def validate_input_default(item: dict[str, Any], dependency_ids: set[str]) -> No
|
||||||
)
|
)
|
||||||
if kind == "static":
|
if kind == "static":
|
||||||
return
|
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"]
|
derive = item["default"]["derive"]
|
||||||
if isinstance(derive, str):
|
if isinstance(derive, str):
|
||||||
if not derive.strip():
|
check_composition_reference(name, derive, "derive", declared)
|
||||||
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"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
if isinstance(derive, dict):
|
if isinstance(derive, dict):
|
||||||
prompt = derive.get("prompt")
|
prompt = derive.get("prompt")
|
||||||
|
|
@ -204,9 +239,9 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
|
||||||
|
|
||||||
names = declared_names(manifest)
|
names = declared_names(manifest)
|
||||||
|
|
||||||
dependency_ids = prompt_dependency_ids(manifest)
|
declared = prompt_dependencies(manifest)
|
||||||
for item in manifest.get("inputs") or []:
|
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")
|
template = template_path.read_text(encoding="utf-8")
|
||||||
placeholders = set(PLACEHOLDER_RE.findall(template))
|
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
|
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]:
|
def parse_semver(value: str) -> tuple[int, int, int, str]:
|
||||||
match = SEMVER_RE.match(value)
|
match = SEMVER_RE.match(value)
|
||||||
if not match:
|
if not match:
|
||||||
|
|
@ -545,15 +619,59 @@ class Resolution:
|
||||||
underivable: list[str] = field(default_factory=list)
|
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.
|
"""Resolve inputs and parameters per § 5.1.
|
||||||
|
|
||||||
This tool never calls a model, so a derived default is satisfied only by its
|
`composer`, when supplied, satisfies `include` defaults by rendering the
|
||||||
static fallback `value`. An input with a derived default and no fallback is
|
included package (§ 10.2). Inclusion is deterministic, so a tool that can
|
||||||
reported as underivable rather than silently rendered as empty.
|
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()
|
result = Resolution()
|
||||||
|
inherited = inherited or {}
|
||||||
known: set[str] = set()
|
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 []
|
inputs = manifest.get("inputs") or []
|
||||||
for item in inputs:
|
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.values[name] = raw_values[name]
|
||||||
result.origins[name] = "supplied"
|
result.origins[name] = "supplied"
|
||||||
continue
|
continue
|
||||||
|
if name in inherited:
|
||||||
|
result.values[name] = inherited[name]
|
||||||
|
result.origins[name] = "inherited"
|
||||||
|
continue
|
||||||
if item.get("required", False):
|
if item.get("required", False):
|
||||||
raise CannedPromptError(f"missing required input: {name}")
|
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":
|
if kind == "static":
|
||||||
result.values[name] = item["default"]
|
result.values[name] = item["default"]
|
||||||
result.origins[name] = "default"
|
result.origins[name] = "default"
|
||||||
|
elif kind == "included":
|
||||||
|
compose(name, item)
|
||||||
elif kind == "derived":
|
elif kind == "derived":
|
||||||
default = item["default"]
|
default = item["default"]
|
||||||
if "value" in default:
|
if "value" in default:
|
||||||
|
|
@ -581,17 +705,10 @@ def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Reso
|
||||||
else:
|
else:
|
||||||
result.origins[name] = "unresolved (optional, no default)"
|
result.origins[name] = "unresolved (optional, no default)"
|
||||||
|
|
||||||
parameters = manifest.get("parameters") or {}
|
# Report inputs before parameters regardless of resolution order.
|
||||||
for name, spec in parameters.items():
|
ordered = {item["name"]: result.origins[item["name"]] for item in inputs}
|
||||||
known.add(name)
|
ordered.update({name: result.origins[name] for name in parameters})
|
||||||
if name in raw_values:
|
result.origins = ordered
|
||||||
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)"
|
|
||||||
|
|
||||||
unknown = sorted(set(raw_values) - known)
|
unknown = sorted(set(raw_values) - known)
|
||||||
if unknown:
|
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
|
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 render_template(template: str, values: dict[str, Any]) -> str:
|
||||||
def replace(match: re.Match[str]) -> str:
|
def replace(match: re.Match[str]) -> str:
|
||||||
name = match.group(1)
|
name = match.group(1)
|
||||||
|
|
@ -625,7 +815,7 @@ def cmd_render(args: argparse.Namespace) -> None:
|
||||||
manifest = validate_package(package_dir)
|
manifest = validate_package(package_dir)
|
||||||
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
||||||
raw = supplied_values(args.set_values)
|
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")
|
template = template_path.read_text(encoding="utf-8")
|
||||||
used = set(PLACEHOLDER_RE.findall(template))
|
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)
|
package_dir, _ = resolve_installed(catalog, args.id, args.version)
|
||||||
manifest = validate_package(package_dir)
|
manifest = validate_package(package_dir)
|
||||||
raw = supplied_values(args.set_values)
|
raw = supplied_values(args.set_values)
|
||||||
resolution = resolve_inputs(manifest, raw)
|
resolution = resolve_inputs(manifest, raw, composer=CatalogComposer(catalog))
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
print(
|
print(
|
||||||
|
|
@ -664,12 +854,13 @@ def cmd_resolve(args: argparse.Namespace) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
width = max((len(name) for name in resolution.origins), default=0)
|
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():
|
for name, origin in resolution.origins.items():
|
||||||
if name in resolution.values:
|
if name in resolution.values:
|
||||||
preview = str(resolution.values[name]).replace("\n", " ")
|
preview = str(resolution.values[name]).replace("\n", " ")
|
||||||
if len(preview) > 48:
|
if len(preview) > 48:
|
||||||
preview = preview[:45] + "..."
|
preview = preview[:45] + "..."
|
||||||
print(f"{name:<{width}} {origin:<32} {preview}")
|
print(f"{name:<{width}} {origin:<{origin_width}} {preview}")
|
||||||
else:
|
else:
|
||||||
print(f"{name:<{width}} {origin}")
|
print(f"{name:<{width}} {origin}")
|
||||||
if resolution.underivable:
|
if resolution.underivable:
|
||||||
|
|
|
||||||
|
|
@ -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")
|
(legacy / "prompt.md").write_text("{{ greeting }}\n", encoding="utf-8")
|
||||||
with pytest.raises(cp.CannedPromptError, match="pre-registry-scoped"):
|
with pytest.raises(cp.CannedPromptError, match="pre-registry-scoped"):
|
||||||
cp.resolve_installed(catalog, "practice/thing", None)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ non-goals and were not touched.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CANP-WP-0002-T03
|
id: CANP-WP-0002-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "0505c790-e57e-568c-9c8c-376957c9b08e"
|
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
|
v0.1 defines no mechanism — `dependencies` is a declared field with no
|
||||||
semantics.
|
semantics.
|
||||||
|
|
||||||
T01 has since settled one corner of this: a derived default binds an input to
|
**Found during the work.** T01 had already delivered half of composition
|
||||||
a prompt dependency declared `requirement: generate`, so package-to-package
|
without it being named as such: a derived default binds an input to a prompt
|
||||||
reference already exists for that one case. Build on it rather than around it.
|
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
|
**Decisions (operator, 2026-09-06):**
|
||||||
references another, whether references are includes, extends, or plain
|
|
||||||
declared prerequisites, and how versions are pinned. Leaning: declaration only
|
- *No inheritance.* A package never extends another, overrides its sections,
|
||||||
— a package states what it needs, and resolution stays with the consumer, per
|
or inherits its inputs. Four of this repo's own documents argue against it:
|
||||||
the INTENT boundary. Do not introduce range resolution (INTENT non-goal).
|
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 14–16; § 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
|
## Canonical eval schemas
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue