Register with Custodian State Hub and seed format open-questions workplan
Register canned-prompts under agents / practice (topic
c1d199b6-55ee-4db6-b49e-257a9f0f15ac, workplan prefix CANP-WP) via
`statehub register`, then replace the generated placeholders with
repo-specific facts.
- SCOPE.md: real boundaries drawn from INTENT.md's deliberate boundary,
current state (spec v0.1 + reference CLI, 3/3 tests pass, example
round-trips), and the developer workflow.
- AGENTS.md: drop the unresolved {CREDENTIAL_ROUTING} template token left
by the generator.
- CANP-WP-0001: bootstrap tasks closed.
- CANP-WP-0002: new workplan carrying the five § 23 open questions promoted
from "experience will decide" to "decide for v0.2" — optional-input
defaults (static or derived), registry namespaces/ownership, prompt
composition, canonical eval schemas, typed context/dependency contracts —
plus two reference-implementation conformance defects found in review
(prerelease versions sort as newest; copy_immutable packages the whole
source directory).
Also lands the previously untracked seed: INTENT.md, the CPF v0.1 spec,
the reference CLI, and examples/pqrst-estimate.
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
2026-09-06 00:45:23 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
import canned_prompts as cp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def package(tmp_path: Path) -> Path:
|
|
|
|
|
pkg = tmp_path / "pkg"
|
|
|
|
|
pkg.mkdir()
|
|
|
|
|
(pkg / "prompt.yaml").write_text(
|
|
|
|
|
"""\
|
|
|
|
|
format: canned-prompt/v0.1
|
|
|
|
|
id: demo/hello
|
|
|
|
|
name: Hello
|
|
|
|
|
version: 1.0.0
|
|
|
|
|
summary: Say hello.
|
|
|
|
|
template: prompt.md
|
|
|
|
|
inputs:
|
|
|
|
|
- name: person
|
|
|
|
|
required: true
|
|
|
|
|
parameters:
|
|
|
|
|
tone:
|
|
|
|
|
type: enum
|
|
|
|
|
values: [warm, formal]
|
|
|
|
|
default: warm
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
(pkg / "prompt.md").write_text(
|
|
|
|
|
"Say hello to {{ person }} in a {{ tone }} tone.\n", encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
return pkg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_and_render(package: Path) -> None:
|
|
|
|
|
manifest = cp.validate_package(package)
|
|
|
|
|
values = cp.resolve_values(manifest, {"person": "Ada"})
|
|
|
|
|
rendered = cp.render_template((package / "prompt.md").read_text(), values)
|
|
|
|
|
assert rendered == "Say hello to Ada in a warm tone.\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_required_input_fails(package: Path) -> None:
|
|
|
|
|
manifest = cp.validate_package(package)
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="missing required input"):
|
|
|
|
|
cp.resolve_values(manifest, {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_undeclared_placeholder_fails(package: Path) -> None:
|
|
|
|
|
(package / "prompt.md").write_text("{{ missing }}\n", encoding="utf-8")
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="undeclared placeholders"):
|
|
|
|
|
cp.validate_package(package)
|
CANP-WP-0002 T01: input defaults, static and derived
Closes the gap that made optional inputs unusable: rendering rule 5.1(4)
made any unresolved placeholder an error while inputs had no `default`, so
an input marked `required: false` and referenced from the template failed
every render in which the caller omitted it — including the spec's own
section 4 example.
Section 10 already carried the derivation mechanism (`requirement: generate`,
resolution deliberately undefined), so a derived default needed a binding
rather than a new concept: the input's default names a declared prompt
dependency.
Spec:
- 5.1 rewritten as "Resolution and rendering". Resolution may be
non-deterministic and must report what it derived; rendering is
deterministic and must not derive. A tool that handles only supplied
values and static defaults is stated to be conforming.
- 6.1 (new) covers both declaration forms. Reference form is preferred, with
the reason stated — an inline prompt is anonymous, so unversioned,
unprovenanced and un-evaluable — and validators should warn when a
published package derives inline.
- A derived default may declare a static fallback `value`. Without one the
input stays unresolved, which is an error; derivation never silently
yields empty content.
- 4, 10, 18 (rules 11-13), 19 (two new MUST NOTs), 21 updated accordingly.
Reference CLI:
- New `resolve` verb reporting the origin of every value.
- `Resolution` dataclass and `resolve_inputs`; `resolve_values` kept as a
wrapper so existing callers are unaffected.
- `render` refuses with a specific error naming underivable inputs rather
than substituting empty text.
- Tests 3 -> 11. Example package lifecycle re-verified end to end.
INTENT.md is unchanged: splitting resolve from render preserves success
criterion 4 (deterministic rendering) as written.
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
2026-09-06 00:59:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_pkg(pkg: Path, manifest: str, template: str = "{{ greeting }}\n") -> Path:
|
|
|
|
|
pkg.mkdir(exist_ok=True)
|
|
|
|
|
(pkg / "prompt.yaml").write_text(manifest, encoding="utf-8")
|
|
|
|
|
(pkg / "prompt.md").write_text(template, encoding="utf-8")
|
|
|
|
|
return pkg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BASE = """\
|
|
|
|
|
format: canned-prompt/v0.1
|
|
|
|
|
id: demo/defaults
|
|
|
|
|
name: Defaults
|
|
|
|
|
version: 1.0.0
|
|
|
|
|
summary: Exercise input defaults.
|
|
|
|
|
template: prompt.md
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_static_default_fills_optional_input(tmp_path: Path) -> None:
|
|
|
|
|
pkg = write_pkg(
|
|
|
|
|
tmp_path / "p",
|
|
|
|
|
BASE
|
|
|
|
|
+ """\
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: false
|
|
|
|
|
default: "hello there"
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
manifest = cp.validate_package(pkg)
|
|
|
|
|
resolution = cp.resolve_inputs(manifest, {})
|
|
|
|
|
assert resolution.values["greeting"] == "hello there"
|
|
|
|
|
assert resolution.origins["greeting"] == "default"
|
|
|
|
|
assert cp.render_template("{{ greeting }}", resolution.values) == "hello there"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_supplied_value_overrides_static_default(tmp_path: Path) -> None:
|
|
|
|
|
pkg = write_pkg(
|
|
|
|
|
tmp_path / "p",
|
|
|
|
|
BASE
|
|
|
|
|
+ """\
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: false
|
|
|
|
|
default: "hello there"
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
manifest = cp.validate_package(pkg)
|
|
|
|
|
resolution = cp.resolve_inputs(manifest, {"greeting": "hi"})
|
|
|
|
|
assert resolution.values["greeting"] == "hi"
|
|
|
|
|
assert resolution.origins["greeting"] == "supplied"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_derived_default_uses_static_fallback(tmp_path: Path) -> None:
|
|
|
|
|
pkg = write_pkg(
|
|
|
|
|
tmp_path / "p",
|
|
|
|
|
BASE
|
|
|
|
|
+ """\
|
|
|
|
|
dependencies:
|
|
|
|
|
prompts:
|
|
|
|
|
- id: context/greeting
|
|
|
|
|
version: 1.0.0
|
|
|
|
|
requirement: generate
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: false
|
|
|
|
|
default:
|
|
|
|
|
derive: context/greeting
|
|
|
|
|
value: "(none)"
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
manifest = cp.validate_package(pkg)
|
|
|
|
|
resolution = cp.resolve_inputs(manifest, {})
|
|
|
|
|
assert resolution.values["greeting"] == "(none)"
|
|
|
|
|
assert resolution.origins["greeting"] == "fallback (not derived)"
|
|
|
|
|
assert resolution.underivable == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_derived_default_without_fallback_is_underivable(tmp_path: Path) -> None:
|
|
|
|
|
pkg = write_pkg(
|
|
|
|
|
tmp_path / "p",
|
|
|
|
|
BASE
|
|
|
|
|
+ """\
|
|
|
|
|
dependencies:
|
|
|
|
|
prompts:
|
|
|
|
|
- id: context/greeting
|
|
|
|
|
version: 1.0.0
|
|
|
|
|
requirement: generate
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: false
|
|
|
|
|
default:
|
|
|
|
|
derive: context/greeting
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
manifest = cp.validate_package(pkg)
|
|
|
|
|
resolution = cp.resolve_inputs(manifest, {})
|
|
|
|
|
assert resolution.underivable == ["greeting"]
|
|
|
|
|
assert "greeting" not in resolution.values
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="unresolved placeholder"):
|
|
|
|
|
cp.render_template("{{ greeting }}", resolution.values)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inline_derive_is_valid(tmp_path: Path) -> None:
|
|
|
|
|
pkg = write_pkg(
|
|
|
|
|
tmp_path / "p",
|
|
|
|
|
BASE
|
|
|
|
|
+ """\
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: false
|
|
|
|
|
default:
|
|
|
|
|
derive:
|
|
|
|
|
prompt: Produce a greeting suited to the audience.
|
|
|
|
|
value: "(none)"
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
manifest = cp.validate_package(pkg)
|
|
|
|
|
assert cp.resolve_inputs(manifest, {}).values["greeting"] == "(none)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_with_required_true_fails(tmp_path: Path) -> None:
|
|
|
|
|
pkg = write_pkg(
|
|
|
|
|
tmp_path / "p",
|
|
|
|
|
BASE
|
|
|
|
|
+ """\
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: true
|
|
|
|
|
default: "hello"
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="required: true"):
|
|
|
|
|
cp.validate_package(pkg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_derive_reference_must_be_declared(tmp_path: Path) -> None:
|
|
|
|
|
pkg = write_pkg(
|
|
|
|
|
tmp_path / "p",
|
|
|
|
|
BASE
|
|
|
|
|
+ """\
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: false
|
|
|
|
|
default:
|
|
|
|
|
derive: context/greeting
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="not declared in dependencies"):
|
|
|
|
|
cp.validate_package(pkg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inline_derive_cannot_also_reference(tmp_path: Path) -> None:
|
|
|
|
|
pkg = write_pkg(
|
|
|
|
|
tmp_path / "p",
|
|
|
|
|
BASE
|
|
|
|
|
+ """\
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: false
|
|
|
|
|
default:
|
|
|
|
|
derive:
|
|
|
|
|
prompt: Produce a greeting.
|
|
|
|
|
id: context/greeting
|
|
|
|
|
""",
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="cannot also reference"):
|
|
|
|
|
cp.validate_package(pkg)
|
CANP-WP-0002 T02: registry-scoped identity and namespace ownership
Section 17 already defined what a registry does when the same id@version is
republished; nothing defined what a *consumer* does. That is where namespace
conflict actually bites — in the catalog, after installing from two
registries.
Identity is now registry-scoped: an id names a package within a registry, the
way a path names a file within a repository. A local-first format with no
signing, no federation and no central authority cannot enforce global
uniqueness, and an unenforceable guarantee is worse than none — it invites
consumers to conflate two packages that merely share a name. A qualified
`<registry>:<id>` reference distinguishes them, and `:` is now barred from ids
so the separator stays available.
Installing the same id from two registries is therefore not a conflict. The
catalog is namespaced by registry and keeps both.
Ownership is registry policy, not package data. An optional `registry.yaml`
names a registry and records namespace claims. Those claims are explicitly
descriptive — a filesystem registry cannot authenticate a publisher, and
`publish` says so rather than implying it checked. Keeping the claim out of
packages leaves artifacts free of unverifiable assertions of authority, and
means package semantics do not change when a hosted registry appears later.
Spec: 3.2 (registry-scoped identity, qualified references), 17 (immutability
scoped to a registry), 20.1 and 20.2 (new), 18 (registry-manifest
validation), 21 (qualified references, reserved `local` name).
Reference CLI: parse_reference, check_registry_name, read_registry_manifest,
registry_name, namespace_policy; registry_package_path and
catalog_package_path split; resolve_installed reports ambiguity and returns
the source registry; iter_catalog; `add --as`; closed-namespace warning on
publish. Tests 11 -> 21.
The catalog layout changed. An existing catalog is detected and reported with
instructions rather than failing as "package not found". Signing, trust
scoring and federation remain non-goals and were not touched.
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
2026-09-06 01:14:54 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
MINIMAL = """\
|
|
|
|
|
format: canned-prompt/v0.1
|
|
|
|
|
id: practice/thing
|
|
|
|
|
name: Thing
|
|
|
|
|
version: 1.0.0
|
|
|
|
|
summary: A thing.
|
|
|
|
|
template: prompt.md
|
|
|
|
|
inputs:
|
|
|
|
|
- name: greeting
|
|
|
|
|
required: false
|
|
|
|
|
default: hi
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_reference() -> None:
|
|
|
|
|
assert cp.parse_reference("practice/thing") == (None, "practice/thing")
|
|
|
|
|
assert cp.parse_reference("house:practice/thing") == ("house", "practice/thing")
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="malformed reference"):
|
|
|
|
|
cp.parse_reference("house:")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_name_falls_back_to_basename(tmp_path: Path) -> None:
|
|
|
|
|
registry = tmp_path / "upstream"
|
|
|
|
|
registry.mkdir()
|
|
|
|
|
assert cp.registry_name(registry) == "upstream"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_name_from_manifest(tmp_path: Path) -> None:
|
|
|
|
|
registry = tmp_path / "some-dir"
|
|
|
|
|
registry.mkdir()
|
|
|
|
|
(registry / "registry.yaml").write_text(
|
|
|
|
|
"format: canned-prompt-registry/v0.1\nname: house\n", encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
assert cp.registry_name(registry) == "house"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_manifest_rejects_bad_format(tmp_path: Path) -> None:
|
|
|
|
|
registry = tmp_path / "r"
|
|
|
|
|
registry.mkdir()
|
|
|
|
|
(registry / "registry.yaml").write_text(
|
|
|
|
|
"format: something-else\nname: house\n", encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="unsupported registry format"):
|
|
|
|
|
cp.read_registry_manifest(registry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_registry_manifest_rejects_bad_policy(tmp_path: Path) -> None:
|
|
|
|
|
registry = tmp_path / "r"
|
|
|
|
|
registry.mkdir()
|
|
|
|
|
(registry / "registry.yaml").write_text(
|
|
|
|
|
"format: canned-prompt-registry/v0.1\n"
|
|
|
|
|
"name: house\n"
|
|
|
|
|
"namespaces:\n practice:\n policy: maybe\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="policy must be"):
|
|
|
|
|
cp.read_registry_manifest(registry)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_namespace_policy(tmp_path: Path) -> None:
|
|
|
|
|
registry = tmp_path / "r"
|
|
|
|
|
registry.mkdir()
|
|
|
|
|
(registry / "registry.yaml").write_text(
|
|
|
|
|
"format: canned-prompt-registry/v0.1\n"
|
|
|
|
|
"name: house\n"
|
|
|
|
|
"namespaces:\n practice:\n owner: Ada\n policy: closed\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
assert cp.namespace_policy(registry, "practice/thing")[0] == "closed"
|
|
|
|
|
assert cp.namespace_policy(registry, "scratch/thing")[0] == "open"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_into(catalog: Path, registry: str) -> None:
|
|
|
|
|
"""Place a package in the catalog under a given registry name."""
|
|
|
|
|
dst = cp.catalog_package_path(catalog, registry, "practice/thing", "1.0.0")
|
|
|
|
|
dst.mkdir(parents=True)
|
|
|
|
|
(dst / "prompt.yaml").write_text(MINIMAL, encoding="utf-8")
|
|
|
|
|
(dst / "prompt.md").write_text("{{ greeting }}\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_same_id_from_two_registries_coexists(tmp_path: Path) -> None:
|
|
|
|
|
catalog = tmp_path / "catalog"
|
|
|
|
|
install_into(catalog, "house")
|
|
|
|
|
install_into(catalog, "upstream")
|
|
|
|
|
assert cp.catalog_registries(catalog) == ["house", "upstream"]
|
|
|
|
|
|
|
|
|
|
package_dir, registry = cp.resolve_installed(catalog, "house:practice/thing", None)
|
|
|
|
|
assert registry == "house"
|
|
|
|
|
assert package_dir.is_dir()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bare_id_in_two_registries_is_ambiguous(tmp_path: Path) -> None:
|
|
|
|
|
catalog = tmp_path / "catalog"
|
|
|
|
|
install_into(catalog, "house")
|
|
|
|
|
install_into(catalog, "upstream")
|
|
|
|
|
with pytest.raises(cp.CannedPromptError, match="more than one registry"):
|
|
|
|
|
cp.resolve_installed(catalog, "practice/thing", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bare_id_in_one_registry_resolves(tmp_path: Path) -> None:
|
|
|
|
|
catalog = tmp_path / "catalog"
|
|
|
|
|
install_into(catalog, "house")
|
|
|
|
|
_, registry = cp.resolve_installed(catalog, "practice/thing", None)
|
|
|
|
|
assert registry == "house"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_legacy_catalog_layout_is_reported(tmp_path: Path) -> None:
|
|
|
|
|
catalog = tmp_path / "catalog"
|
|
|
|
|
legacy = catalog / "practice" / "thing" / "1.0.0"
|
|
|
|
|
legacy.mkdir(parents=True)
|
|
|
|
|
(legacy / "prompt.yaml").write_text(MINIMAL, encoding="utf-8")
|
|
|
|
|
(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)
|