CANP-WP-0002 T07: semver precedence and strict packaging

Two defects from the original review of the seed.

Prerelease ordering was worse than first recorded. parse_semver returned
(major, minor, patch, raw_string), so 1.0.0-rc1 and 1.0.0 tied on the numeric
fields and then compared as strings — "1.0.0-rc1" > "1.0.0". A release
candidate therefore shadowed its own release for `newest` and for `>=`, not
just for the no-version case.

parse_semver now returns a SemVer section 11 precedence key: numeric fields, a
release/prerelease rank, then dot-separated prerelease identifiers with
numeric ones compared numerically. Build metadata is ignored. Beyond ordering,
prereleases are excluded from `any`, `newest` and `>=` entirely; only an exact
pin selects one, so publishing a release candidate never changes what existing
consumers resolve to. A package holding only prereleases now says so rather
than reporting a bare not-found.

Packaging copied the whole source directory, so a stray .git, virtualenv or
scratch file landed in the catalog and registry. Section 2 already required
otherwise — tools MUST ignore unknown non-reserved files unless a manifest
field references them — so this is conformance rather than a new rule. What is
new is that omissions are reported instead of silent:

  not packaged (not a reserved path, not referenced by the manifest): .git/, .venv/, notes.txt

LICENSE joins the reserved paths. Strict packaging would otherwise drop a
package's license text while faithfully copying its `license` field, which
contradicts section 14's instruction to surface licensing on publish and
install.

Spec: 2 (LICENSE, packaging obligation, reporting), 17.1 new, 10.3 note.

Reference CLI: parse_semver rewritten with is_prerelease; select_version and
pick_version updated; copy_package and report_skipped replace copy_immutable.
Tests 65 -> 78.

examples/pqrst-estimate carries a LICENSE and a license field, exercising the
new reserved path.

Also fixes a leaked loop variable in package_members that would have reported
a bad `template` path as an `evals` error.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
This commit is contained in:
tegwick 2026-09-06 09:32:42 +02:00
parent e885eb7b05
commit 95a8bb31d2
8 changed files with 306 additions and 34 deletions

View file

@ -53,12 +53,24 @@ my-prompt/
| `prompt.yaml` | Required package manifest | | `prompt.yaml` | Required package manifest |
| `prompt.md` | Default prompt template unless overridden by `template` | | `prompt.md` | Default prompt template unless overridden by `template` |
| `README.md` | Optional human documentation | | `README.md` | Optional human documentation |
| `LICENSE` | Optional license text |
| `examples/` | Optional examples/fixtures | | `examples/` | Optional examples/fixtures |
| `evals/` | Optional evaluation specifications | | `evals/` | Optional evaluation specifications |
| `assets/` | Optional supporting text/data artifacts | | `assets/` | Optional supporting text/data artifacts |
Tools MUST ignore unknown non-reserved files unless a manifest field explicitly references them. Tools MUST ignore unknown non-reserved files unless a manifest field explicitly references them.
This governs packaging as well as reading: a tool that copies a package into a
catalog or registry MUST copy the reserved paths and the files a manifest
field references, and nothing else. A working directory's `.git`, virtualenv or
scratch files are not part of the package and MUST NOT be published with it. A
tool that omits files SHOULD say which, so that an author is never silently
surprised by what did not ship.
`LICENSE` is reserved because § 14 asks tools to surface licensing on publish
and install, and because dropping the license text while faithfully copying the
`license` field would misrepresent the package.
## 3. Manifest ## 3. Manifest
The canonical manifest is UTF-8 YAML named `prompt.yaml`. The canonical manifest is UTF-8 YAML named `prompt.yaml`.
@ -680,6 +692,9 @@ reported as ambiguous rather than chosen.
| `newest` | the newest available version | | `newest` | the newest available version |
| `>= 1.2.0` | the newest available version that is at least `1.2.0` | | `>= 1.2.0` | the newest available version that is at least `1.2.0` |
Prereleases are excluded from `any`, `newest` and `>=` (§ 17.1); name one
exactly to select it.
**An exact pin is the expected form.** The other three are explicit opt-ins, **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 visible in the manifest, so that looseness is always something an author wrote
rather than something absence implied. rather than something absence implied.
@ -887,6 +902,17 @@ both is not in a conflict — it is holding two things whose names happen to
coincide. Only a repeat publication *within one registry* violates coincide. Only a repeat publication *within one registry* violates
immutability. immutability.
### 17.1 Precedence and prereleases
Where versions are compared, precedence follows Semantic Versioning: numeric
fields first, and a prerelease version ranks **below** its own release, so
`1.0.0-rc1` precedes `1.0.0`. Build metadata does not affect precedence.
A prerelease is **not selected** by `any`, `newest`, or a `>=` lower bound
(§ 10.3). Only an exact pin selects one. Publishing a release candidate
therefore never changes what existing consumers resolve to — which is the
point of marking it a candidate.
Suggested versioning guidance: Suggested versioning guidance:
- PATCH: wording/metadata correction with intended behavior unchanged; - PATCH: wording/metadata correction with intended behavior unchanged;

View file

@ -181,6 +181,22 @@ signing and trust scoring are explicit non-goals. Ownership lives with the
registry rather than in the package, so no package carries an unverifiable registry rather than in the package, so no package carries an unverifiable
assertion of authority. assertion of authority.
## Packaging and versions
`add`, `publish` and `install` copy the reserved paths (`prompt.yaml`,
`prompt.md`, `README.md`, `LICENSE`, `examples/`, `evals/`, `assets/`) plus
anything a manifest field references — and nothing else, as § 2 requires. What
was left behind is reported rather than silently dropped:
```text
not packaged (not a reserved path, not referenced by the manifest): .git/, .venv/, notes.txt
```
Version precedence follows SemVer, so `1.0.0` outranks `1.0.0-rc1`, and
`any`, `newest` and `>= X.Y.Z` skip prereleases entirely. Publishing a release
candidate never changes what existing consumers resolve to; name it exactly to
use it.
## Deliberate limitations ## Deliberate limitations
This seed has no hosted registry, model execution, authentication, network access, dependency resolver, or social features. `publish` and `install` operate on a filesystem registry so that the package semantics can be tested before infrastructure is built around them. This seed has no hosted registry, model execution, authentication, network access, dependency resolver, or social features. `publish` and `install` operate on a filesystem registry so that the package semantics can be tested before infrastructure is built around them.

View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 canned-prompts contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.

View file

@ -45,6 +45,7 @@ tags:
- retrospective - retrospective
- agentic-coding - agentic-coding
- effort-estimation - effort-estimation
license: MIT
provenance: provenance:
author: canned-prompts seed author: canned-prompts seed
examples: examples:

View file

@ -66,6 +66,12 @@ rather than resolved by guessing.
- `{{ name }}` template substitution only. - `{{ name }}` template substitution only.
- No arbitrary expression/code execution. - No arbitrary expression/code execution.
- Published versions are immutable by default. - Published versions are immutable by default.
- `add`, `publish` and `install` copy only reserved paths and manifest-
referenced files. A working directory's `.git`, virtualenv or scratch files
never ship; whatever is left out is named on stderr.
- Version precedence follows SemVer: a prerelease ranks below its release, and
`any`, `newest` and `>=` skip prereleases entirely. Only an exact pin
selects one.
- `install` copies from registry to catalog. - `install` copies from registry to catalog.
- `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

View file

@ -26,8 +26,14 @@ REGISTRY_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
EVAL_RUBRIC_SCHEMA = "canned-prompts/eval-rubric/v0.1" EVAL_RUBRIC_SCHEMA = "canned-prompts/eval-rubric/v0.1"
RENDER_CHECKS = ("contains", "not_contains", "resolves_all") RENDER_CHECKS = ("contains", "not_contains", "resolves_all")
CAPABILITY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") CAPABILITY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
RESERVED_FILES = ("prompt.yaml", "README.md", "LICENSE")
RESERVED_DIRS = ("examples", "evals", "assets")
PLACEHOLDER_RE = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}") PLACEHOLDER_RE = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+].*)?$") SEMVER_RE = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
REQUIRED_FIELDS = ("format", "id", "name", "version", "summary", "template") REQUIRED_FIELDS = ("format", "id", "name", "version", "summary", "template")
@ -481,25 +487,52 @@ def select_version(available: list[str], selector: str | None) -> str | None:
Each dependency is selected independently against what is present. There is Each dependency is selected independently against what is present. There is
no constraint solving across a dependency graph; that stays a non-goal. no constraint solving across a dependency graph; that stays a non-goal.
Prereleases are excluded from `any`, `newest` and `>=` (§ 17.1); only an
exact pin selects one, so publishing a release candidate never changes what
existing consumers resolve to.
""" """
if not available: if not available:
return None return None
if selector is not None and selector not in ("any", "newest") and not selector.startswith(">="):
return selector if selector in available else None
stable = [version for version in available if not is_prerelease(version)]
if not stable:
return None
if selector is None or selector in ("any", "newest"): if selector is None or selector in ("any", "newest"):
return available[0] return stable[0]
if selector.startswith(">="):
floor = parse_semver(selector[2:]) floor = parse_semver(selector[2:])
for version in available: for version in stable:
if parse_semver(version) >= floor: if parse_semver(version) >= floor:
return version return version
return None 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[Any, ...]:
"""A precedence key following SemVer § 11 (spec § 17.1).
A prerelease ranks below its own release, so `1.0.0-rc1` < `1.0.0`. Build
metadata is ignored. Unparseable versions sort below everything.
"""
match = SEMVER_RE.match(value) match = SEMVER_RE.match(value)
if not match: if not match:
return (-1, -1, -1, value) return (-1, -1, -1, 0, (), value)
return (int(match.group(1)), int(match.group(2)), int(match.group(3)), value) major, minor, patch = (int(match.group(i)) for i in (1, 2, 3))
prerelease = match.group(4)
if prerelease is None:
# 1 outranks the 0 given to a prerelease of the same numeric version.
return (major, minor, patch, 1, (), "")
identifiers: list[tuple[int, int, str]] = []
for part in prerelease.split("."):
# Numeric identifiers compare numerically and rank below alphanumeric ones.
identifiers.append((0, int(part), "") if part.isdigit() else (1, 0, part))
return (major, minor, patch, 0, tuple(identifiers), "")
def is_prerelease(value: str) -> bool:
match = SEMVER_RE.match(value)
return bool(match and match.group(4) is not None)
def versions_at(base: Path) -> list[str]: def versions_at(base: Path) -> list[str]:
@ -510,15 +543,21 @@ def versions_at(base: Path) -> list[str]:
def pick_version(base: Path, label: str, version: str | None) -> Path: def pick_version(base: Path, label: str, version: str | None) -> Path:
available = versions_at(base)
if version: if version:
path = base / version path = base / version
if not (path / "prompt.yaml").is_file(): if not (path / "prompt.yaml").is_file():
raise CannedPromptError(f"package not found: {label}@{version}") raise CannedPromptError(f"package not found: {label}@{version}")
return path return path
versions = versions_at(base) if not available:
if not versions:
raise CannedPromptError(f"package not found: {label}") raise CannedPromptError(f"package not found: {label}")
return base / versions[0] chosen = select_version(available, None)
if chosen is None:
raise CannedPromptError(
f"{label}: only prerelease versions are available "
f"({', '.join(available)}); name one exactly to use it"
)
return base / chosen
def resolve_in_registry(registry: Path, package_id: str, version: str | None) -> Path: def resolve_in_registry(registry: Path, package_id: str, version: str | None) -> Path:
@ -578,11 +617,69 @@ def resolve_installed(
return pick_version(id_path(catalog / name, package_id), f"{name}:{package_id}", version), name return pick_version(id_path(catalog / name, package_id), f"{name}:{package_id}", version), name
def copy_immutable(src: Path, dst: Path, what: str) -> None: def package_members(package_dir: Path, manifest: dict[str, Any]) -> set[str]:
"""Relative paths that belong to the package (§ 2).
Reserved paths plus whatever a manifest field references. Everything else in
the working directory — `.git`, a virtualenv, scratch files — is not part of
the package.
"""
members: set[str] = set()
for name in RESERVED_FILES:
if (package_dir / name).is_file():
members.add(name)
for name in RESERVED_DIRS:
directory = package_dir / name
if directory.is_dir():
for path in directory.rglob("*"):
if path.is_file():
members.add(path.relative_to(package_dir).as_posix())
referenced = [("template", manifest["template"])]
for key in ("examples", "evals"):
referenced.extend((key, relative) for relative in (manifest.get(key) or []))
for field, relative in referenced:
resolved = safe_relative_file(package_dir, relative, field)
members.add(resolved.relative_to(package_dir.resolve()).as_posix())
return members
def skipped_entries(package_dir: Path, members: set[str]) -> list[str]:
"""Top-level entries that will not be packaged.
Reported by name only; nothing walks into an ignored directory, so a stray
virtualenv costs nothing to skip.
"""
covered = {member.split("/", 1)[0] for member in members}
skipped = []
for entry in sorted(package_dir.iterdir()):
if entry.name in covered:
continue
skipped.append(entry.name + ("/" if entry.is_dir() else ""))
return skipped
def copy_package(src: Path, dst: Path, manifest: dict[str, Any], what: str) -> list[str]:
"""Copy only what belongs to the package (§ 2). Returns what was skipped."""
if dst.exists(): if dst.exists():
raise CannedPromptError(f"{what} already exists: {dst}") raise CannedPromptError(f"{what} already exists: {dst}")
dst.parent.mkdir(parents=True, exist_ok=True) members = package_members(src, manifest)
shutil.copytree(src, dst) skipped = skipped_entries(src, members)
dst.mkdir(parents=True)
for relative in sorted(members):
target = dst / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src / relative, target)
return skipped
def report_skipped(skipped: list[str]) -> None:
if skipped:
print(
"not packaged (not a reserved path, not referenced by the manifest): "
+ ", ".join(skipped),
file=sys.stderr,
)
def iter_catalog(catalog: Path) -> Iterable[tuple[str, Path, dict[str, Any]]]: def iter_catalog(catalog: Path) -> Iterable[tuple[str, Path, dict[str, Any]]]:
@ -602,7 +699,7 @@ def cmd_add(args: argparse.Namespace) -> None:
catalog = Path(args.catalog).expanduser() catalog = Path(args.catalog).expanduser()
registry = check_registry_name(args.as_registry) registry = check_registry_name(args.as_registry)
dst = catalog_package_path(catalog, registry, manifest["id"], manifest["version"]) dst = catalog_package_path(catalog, registry, manifest["id"], manifest["version"])
copy_immutable(src.resolve(), dst, "catalog package") report_skipped(copy_package(src.resolve(), dst, manifest, "catalog package"))
print(f"added {registry}:{manifest['id']}@{manifest['version']} -> {dst}") print(f"added {registry}:{manifest['id']}@{manifest['version']} -> {dst}")
@ -623,7 +720,7 @@ def cmd_publish(args: argparse.Namespace) -> None:
) )
dst = registry_package_path(registry, manifest["id"], manifest["version"]) dst = registry_package_path(registry, manifest["id"], manifest["version"])
copy_immutable(src.resolve(), dst, "published package") report_skipped(copy_package(src.resolve(), dst, manifest, "published package"))
label = f"{name}:{manifest['id']}" if name else manifest["id"] label = f"{name}:{manifest['id']}" if name else manifest["id"]
print(f"published {label}@{manifest['version']} -> {dst}") print(f"published {label}@{manifest['version']} -> {dst}")
@ -642,7 +739,7 @@ def cmd_install(args: argparse.Namespace) -> None:
src = resolve_in_registry(registry, package_id, args.version) src = resolve_in_registry(registry, package_id, args.version)
manifest = validate_package(src) manifest = validate_package(src)
dst = catalog_package_path(catalog, name, manifest["id"], manifest["version"]) dst = catalog_package_path(catalog, name, manifest["id"], manifest["version"])
copy_immutable(src, dst, "catalog package") copy_package(src, dst, manifest, "catalog package")
print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}") print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}")

View file

@ -680,3 +680,85 @@ def test_capability_may_be_both_required_and_observed(tmp_path: Path) -> None:
manifest = cp.validate_package(pkg) manifest = cp.validate_package(pkg)
assert manifest["dependencies"]["capabilities"] == ["web-search"] assert manifest["dependencies"]["capabilities"] == ["web-search"]
assert "long-context" in manifest["compatibility"]["capabilities"] assert "long-context" in manifest["compatibility"]["capabilities"]
# --- semver precedence and prereleases (§ 17.1) ---
def test_release_outranks_its_prerelease() -> None:
assert cp.parse_semver("1.0.0") > cp.parse_semver("1.0.0-rc1")
def test_prerelease_ordering_follows_semver() -> None:
ordered = sorted(
["1.0.0", "1.0.0-rc.2", "1.0.0-rc.10", "1.0.0-alpha", "0.9.0"],
key=cp.parse_semver,
reverse=True,
)
assert ordered == ["1.0.0", "1.0.0-rc.10", "1.0.0-rc.2", "1.0.0-alpha", "0.9.0"]
def test_build_metadata_is_ignored_for_precedence() -> None:
assert cp.parse_semver("1.0.0+build.1") == cp.parse_semver("1.0.0+build.2")
@pytest.mark.parametrize("selector", ["newest", "any", ">=1.0.0", None])
def test_prerelease_is_not_selected_implicitly(selector) -> None:
available = ["1.1.0-rc1", "1.0.0"]
assert cp.select_version(available, selector) == "1.0.0"
def test_exact_pin_selects_a_prerelease() -> None:
assert cp.select_version(["1.1.0-rc1", "1.0.0"], "1.1.0-rc1") == "1.1.0-rc1"
def test_only_prereleases_available_selects_nothing() -> None:
assert cp.select_version(["1.0.0-rc1"], "newest") is None
def test_prerelease_only_package_reports_why(tmp_path: Path) -> None:
catalog = tmp_path / "catalog"
place(catalog, "local", "practice/thing", "1.0.0-rc1", MINIMAL.replace(
"version: 1.0.0", "version: 1.0.0-rc1"), "{{ greeting }}\n")
with pytest.raises(cp.CannedPromptError, match="only prerelease versions"):
cp.resolve_installed(catalog, "practice/thing", None)
# --- strict packaging (§ 2) ---
def test_only_reserved_and_referenced_paths_are_packaged(tmp_path: Path) -> None:
src = write_evaluated(tmp_path / "src", RUBRIC)
(src / "LICENSE").write_text("MIT\n", encoding="utf-8")
(src / "notes.txt").write_text("scratch\n", encoding="utf-8")
(src / ".git").mkdir()
(src / ".git" / "config").write_text("junk\n", encoding="utf-8")
manifest = cp.validate_package(src)
dst = tmp_path / "out"
skipped = cp.copy_package(src, dst, manifest, "package")
packaged = sorted(p.relative_to(dst).as_posix() for p in dst.rglob("*") if p.is_file())
assert packaged == [
"LICENSE",
"evals/quality.yaml",
"examples/basic.yaml",
"prompt.md",
"prompt.yaml",
]
assert skipped == [".git/", "notes.txt"]
def test_packaged_copy_is_still_valid(tmp_path: Path) -> None:
src = write_evaluated(tmp_path / "src", RUBRIC)
manifest = cp.validate_package(src)
dst = tmp_path / "out"
cp.copy_package(src, dst, manifest, "package")
assert cp.validate_package(dst)["id"] == "demo/evaluated"
def test_copy_refuses_to_overwrite(tmp_path: Path) -> None:
src = write_evaluated(tmp_path / "src", RUBRIC)
manifest = cp.validate_package(src)
dst = tmp_path / "out"
cp.copy_package(src, dst, manifest, "package")
with pytest.raises(cp.CannedPromptError, match="already exists"):
cp.copy_package(src, dst, manifest, "package")

View file

@ -388,22 +388,57 @@ the format revision warrants it.
```task ```task
id: CANP-WP-0002-T07 id: CANP-WP-0002-T07
status: todo status: done
priority: low priority: low
state_hub_task_id: "f642abe6-8222-5958-88ee-7a53454f2344" state_hub_task_id: "f642abe6-8222-5958-88ee-7a53454f2344"
``` ```
Two defects found in the same review, independent of the format questions: Two defects found in the same review, independent of the format questions:
1. **Prerelease versions sort as newest.** `parse_semver` in 1. **Prerelease versions sorted as newest.** Confirmed worse than first
`reference/canned_prompts.py` returns `(major, minor, patch, raw_string)`, recorded: `1.0.0-rc1` shadowed `1.0.0` for `newest` *and* `>= 1.0.0`, so a
so `1.0.0-rc1` and `1.0.0` tie on the numeric fields and then compare as release candidate hid its own release from every selector.
strings — `"1.0.0-rc1" > "1.0.0"`. `resolve_installed` with no `--version` 2. **`copy_immutable` copied everything**, so a stray `.git`, `.venv` or
therefore selects a prerelease over its own release. Order prerelease below scratch file landed in the catalog and registry.
release, or state in § 17 that v0.1 ignores prerelease ordering.
2. **`copy_immutable` copies everything.** `add`/`publish` use **Decisions (operator, 2026-09-06):**
`shutil.copytree` over the whole source directory, so a stray `.git`,
`.venv`, or scratch file lands in the catalog and registry. § 2 says tools - *Prereleases are excluded unless named.* They sort below their release and
MUST ignore unknown non-reserved files unless a manifest field references are skipped by `any`, `newest` and `>=`; only an exact pin selects one.
them. Decide whether packaging is reserved-paths-only or an explicit Publishing a release candidate therefore never changes what existing
ignore list, then align the implementation and § 2. consumers resolve to — the point of marking it a candidate. Matches npm and
cargo.
- *`LICENSE` joins the reserved paths.* Strict packaging would otherwise drop
a package's license text while faithfully copying its `license` field, which
contradicts § 14's instruction to surface licensing on publish and install.
The strict-versus-ignore-list question needed no decision: § 2 already
required it — "Tools MUST ignore unknown non-reserved files unless a manifest
field explicitly references them" — so packaging by reserved path is
conformance, not a new choice. What was worth adding is that omissions are
**reported**, never silent.
Delivered:
1. `parse_semver` now returns a SemVer § 11 precedence key: numeric fields,
then a release/prerelease rank, then dot-separated prerelease identifiers
with numeric ones compared numerically. Build metadata is ignored, so
`1.0.0+build.1` and `1.0.0+build.2` tie. `1.0.0-rc.10` correctly outranks
`1.0.0-rc.2`.
2. `select_version` excludes prereleases from `any`, `newest` and `>=`;
`pick_version` reports "only prerelease versions are available" rather than
a bare not-found.
3. `copy_package` replaces `copy_immutable`, copying reserved paths plus
manifest-referenced files only, and returning what it skipped;
`report_skipped` names those on stderr. `skipped_entries` reports top-level
names without walking into an ignored directory, so a stray virtualenv
costs nothing to skip.
4. § 2 gains `LICENSE`, the packaging obligation, and the reporting SHOULD;
§ 17.1 (new) specifies precedence and prerelease selection; § 10.3 notes
the exclusion.
5. `examples/pqrst-estimate` now carries a `LICENSE` and a `license: MIT`
field, exercising the new reserved path. Tests 65 → 78.
**Fixed while implementing.** `package_members` reused a loop variable as the
error label, so a bad `template` path would have been reported as an `evals`
error.