From 4a56f202097b8412fc21974d4284d94d82a9668b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 01:31:58 +0200 Subject: [PATCH] CANP-WP-0002 T03: composition by reference, two kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- CannedPromptFormat-v0.1.md | 143 +++++++++- README.md | 40 ++- examples/house-style/README.md | 27 ++ examples/house-style/prompt.md | 5 + examples/house-style/prompt.yaml | 23 ++ examples/pqrst-estimate/prompt.md | 2 + examples/pqrst-estimate/prompt.yaml | 15 +- reference/README.md | 2 + reference/canned_prompts.py | 265 +++++++++++++++--- reference/tests/test_canned_prompts.py | 168 +++++++++++ .../CANP-WP-0002-format-open-questions.md | 65 ++++- 11 files changed, 697 insertions(+), 58 deletions(-) create mode 100644 examples/house-style/README.md create mode 100644 examples/house-style/prompt.md create mode 100644 examples/house-style/prompt.yaml diff --git a/CannedPromptFormat-v0.1.md b/CannedPromptFormat-v0.1.md index a316f58..bcc00a3 100644 --- a/CannedPromptFormat-v0.1.md +++ b/CannedPromptFormat-v0.1.md @@ -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 `:` 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; diff --git a/README.md b/README.md index 47c35ad..ab40b6c 100644 --- a/README.md +++ b/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. - [`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` diff --git a/examples/house-style/README.md b/examples/house-style/README.md new file mode 100644 index 0000000..0ca9c97 --- /dev/null +++ b/examples/house-style/README.md @@ -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. diff --git a/examples/house-style/prompt.md b/examples/house-style/prompt.md new file mode 100644 index 0000000..b6a5cfc --- /dev/null +++ b/examples/house-style/prompt.md @@ -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. diff --git a/examples/house-style/prompt.yaml b/examples/house-style/prompt.yaml new file mode 100644 index 0000000..a55d391 --- /dev/null +++ b/examples/house-style/prompt.yaml @@ -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 diff --git a/examples/pqrst-estimate/prompt.md b/examples/pqrst-estimate/prompt.md index 6c1c84c..b44d441 100644 --- a/examples/pqrst-estimate/prompt.md +++ b/examples/pqrst-estimate/prompt.md @@ -1,3 +1,5 @@ +{{ house_style }} + Review the coding session described below and produce a **PQRST Estimate** of where effort was spent. diff --git a/examples/pqrst-estimate/prompt.yaml b/examples/pqrst-estimate/prompt.yaml index e4f157c..6e65221 100644 --- a/examples/pqrst-estimate/prompt.yaml +++ b/examples/pqrst-estimate/prompt.yaml @@ -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 diff --git a/reference/README.md b/reference/README.md index 4e0592d..b1c1798 100644 --- a/reference/README.md +++ b/reference/README.md @@ -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 `:` 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. diff --git a/reference/canned_prompts.py b/reference/canned_prompts.py index ac9c448..f015100 100755 --- a/reference/canned_prompts.py +++ b/reference/canned_prompts.py @@ -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: diff --git a/reference/tests/test_canned_prompts.py b/reference/tests/test_canned_prompts.py index 245e1d1..2c489cd 100644 --- a/reference/tests/test_canned_prompts.py +++ b/reference/tests/test_canned_prompts.py @@ -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) diff --git a/workplans/CANP-WP-0002-format-open-questions.md b/workplans/CANP-WP-0002-format-open-questions.md index 625bbe2..6a47ff4 100644 --- a/workplans/CANP-WP-0002-format-open-questions.md +++ b/workplans/CANP-WP-0002-format-open-questions.md @@ -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 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