Add the catalog index, and package a real prompt collection

CANP-WP-0004. The operator asked canned-prompts to build a database of
versioned prompts recording where each came from and when — the first step
toward a platform for collaborative prompting.

The format had nowhere to put that. `provenance` records who wrote a prompt and
where the idea came from; nothing recorded how a copy arrived in a particular
store. Section 20.3 now specifies an `index.yaml` as store metadata rather than
package data: how a copy arrived differs for every consumer, and recording an
arrival must never rewrite the package that arrived.

`add`, `install` and `publish` record registry, id, version, name, source,
method, first-inclusion date, and the package's declared author, source and
licence — the last three copied so a listing is readable without opening every
package. A new `index` verb lists it. `included_at` is never overwritten; a
re-run updates `last_seen_at`, because when a package first entered a
collection is a fact about history rather than about the last command run.

Tests 84 -> 90.

Also records two findings from actually using the format:

CANP-WP-0004-T03 — inclusion has no deduplication, so a diamond dependency
renders shared content once per path. Found by composing a real collection.
Not fixed here: deduplicating means choosing which occurrence survives and
deciding what happens when two paths resolve different versions, which is
resolver behaviour that section 10.4 deliberately avoids. Handed to
CANP-WP-0005 with a leaning: document it, warn at validation time, do not
deduplicate.

The add_ons workaround in practice/pqrst-estimate is evidence about section
23's deferred "richer template syntax" — an optional appendix has to be an
input with an empty default, because CPF has no conditionals.

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 17:14:21 +02:00
parent f55ef13c75
commit b3280df742
7 changed files with 386 additions and 1 deletions

View file

@ -1105,6 +1105,50 @@ registry/
A registry's own layout is not namespaced by registry name: within one A registry's own layout is not namespaced by registry name: within one
registry, an id is unambiguous by definition. registry, an id is unambiguous by definition.
### 20.3 The index
A store MAY keep an `index.yaml` at its root recording which package versions
entered it, from where, and when:
```yaml
format: canned-prompt-index/v0.1
entries:
- registry: local
id: helix/repo-orient
version: 0.1.0
name: Repo Orientation
source: /home/worsch/helix-forge/prompts/repo-orient
method: add
included_at: "2026-09-06T13:31:02Z"
declared_author: Bernd Worsch
declared_source: personal prompt collection, contributed 2026-09-06
license: MIT
```
This is deliberately **not** package data. `provenance` (§ 13) records who wrote
a prompt and where the idea came from; the index records how a copy arrived in
*this* store — a fact about the store, not about the artifact, and one that
would differ for every consumer. Keeping it outside preserves immutability
(§ 17): recording an arrival never rewrites the package that arrived.
| Field | Meaning |
|---|---|
| `registry`, `id`, `version` | Which package version this entry is about |
| `source` | Where the copy came from: a path, a registry, a URL |
| `method` | How it arrived: `add`, `install`, `publish` |
| `included_at` | When it **first** entered this store |
| `last_seen_at` | When it was most recently re-recorded, if ever |
| `declared_author`, `declared_source` | Copied from the package's own `provenance`, so the index is readable without opening every package |
| `license` | Copied from the manifest, so licensing is visible in a listing |
`included_at` records first arrival and MUST NOT be overwritten when the same
`registry`/`id`/`version` is recorded again; a re-run updates `last_seen_at`
instead. When a package first entered a collection is a fact about history, not
about the last time someone ran a command.
Because identity is registry-scoped (§ 3.2), the same `id@version` from two
registries is two entries, not a conflict.
## 21. Reference CLI semantics ## 21. Reference CLI semantics
The reference tool uses two stores: The reference tool uses two stores:
@ -1138,6 +1182,7 @@ satisfy.
```text ```text
eval ID run an installed package's render checks eval ID run an installed package's render checks
index [ID] show what entered this catalog, from where and when
``` ```
`eval` runs the deterministic render checks of every recognized eval file and `eval` runs the deterministic render checks of every recognized eval file and

View file

@ -181,6 +181,26 @@ 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.
## The index
Every store keeps an `index.yaml` recording which package versions entered it,
from where, and when:
```bash
python canned_prompts.py index
```
```text
local:helix/repo-orient@0.1.0 2026-09-06T13:31:02Z add
from /home/worsch/helix-forge/prompts/repo-orient
declares source personal prompt collection, contributed 2026-09-06
```
This is store metadata, not package data. `provenance` records who wrote a
prompt; the index records how a copy arrived *here* — which differs for every
consumer, and which must never rewrite the package it describes. `included_at`
is first arrival and is never overwritten; a re-run updates `last_seen_at`.
## Dependencies are reported, not fetched ## Dependencies are reported, not fetched
Installing a package that composes another warns when the dependency is Installing a package that composes another warns when the dependency is

View file

@ -2,7 +2,7 @@
This is intentionally a **small reference implementation**, not the intended final architecture. This is intentionally a **small reference implementation**, not the intended final architecture.
It demonstrates eight verbs: It demonstrates nine verbs:
```text ```text
add PATH add PATH
@ -11,6 +11,7 @@ show ID
resolve ID --set key=value resolve ID --set key=value
render ID --set key=value render ID --set key=value
eval ID eval ID
index [ID]
install ID [--version VERSION] install ID [--version VERSION]
publish PATH publish PATH
``` ```
@ -87,6 +88,10 @@ rather than resolved by guessing.
- `add` and `install` name declared prompt dependencies the catalog cannot - `add` and `install` name declared prompt dependencies the catalog cannot
satisfy. They do not fetch them — resolution is the consumer's job (§ 10) — satisfy. They do not fetch them — resolution is the consumer's job (§ 10) —
but a package that looks installed and cannot render should say so. but a package that looks installed and cannot render should say so.
- `add`, `install` and `publish` record an entry in the store's `index.yaml`:
source, method, first-inclusion date, and the package's declared author,
source and licence. `index` lists it. Re-adding keeps the original
`included_at` and updates `last_seen_at`.
- 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.

View file

@ -11,6 +11,7 @@ import argparse
import json import json
import os import os
import re import re
import datetime as dt
import shutil import shutil
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -30,6 +31,8 @@ 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_FILES = ("prompt.yaml", "README.md", "LICENSE")
RESERVED_DIRS = ("examples", "evals", "assets") RESERVED_DIRS = ("examples", "evals", "assets")
INDEX_FILE = "index.yaml"
INDEX_FORMAT = "canned-prompt-index/v0.1"
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( SEMVER_RE = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
@ -678,6 +681,76 @@ def copy_package(src: Path, dst: Path, manifest: dict[str, Any], what: str) -> l
return skipped return skipped
def read_index(store: Path) -> dict[str, Any]:
"""The catalog index: which packages entered this store, from where, when.
Registry-side metadata, deliberately outside the immutable package (§ 20).
A package records who wrote it; the index records how it got here.
"""
path = store / INDEX_FILE
if not path.is_file():
return {"format": INDEX_FORMAT, "entries": []}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise CannedPromptError(f"invalid YAML in {path}: {exc}") from exc
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
raise CannedPromptError(f"{path}: index must be a mapping with an entries list")
if data.get("format") != INDEX_FORMAT:
raise CannedPromptError(f"unsupported index format: {data.get('format')!r}")
return data
def index_entry_key(entry: dict[str, Any]) -> tuple[str, str, str]:
return (entry.get("registry", ""), entry.get("id", ""), entry.get("version", ""))
def record_inclusion(
store: Path,
registry: str,
manifest: dict[str, Any],
source: str,
method: str,
) -> dict[str, Any]:
"""Record that a package version entered this store, and from where.
Re-recording the same registry/id/version keeps the original `included_at`:
the date a package first entered is a fact about history, not about the last
time someone re-ran a command.
"""
index = read_index(store)
entry = {
"registry": registry,
"id": manifest["id"],
"version": manifest["version"],
"name": manifest.get("name"),
"source": source,
"method": method,
"included_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
provenance = manifest.get("provenance") or {}
if isinstance(provenance, dict):
for field in ("author", "source"):
if provenance.get(field):
entry[f"declared_{field}"] = str(provenance[field])
if manifest.get("license"):
entry["license"] = manifest["license"]
existing = {index_entry_key(e): e for e in index["entries"]}
key = index_entry_key(entry)
if key in existing:
entry["included_at"] = existing[key].get("included_at", entry["included_at"])
entry["last_seen_at"] = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
existing[key] = entry
index["entries"] = [existing[k] for k in sorted(existing)]
store.mkdir(parents=True, exist_ok=True)
(store / INDEX_FILE).write_text(
yaml.safe_dump(index, sort_keys=False, allow_unicode=True), encoding="utf-8"
)
return entry
def missing_dependencies(catalog: Path, manifest: dict[str, Any]) -> list[str]: def missing_dependencies(catalog: Path, manifest: dict[str, Any]) -> list[str]:
"""Declared prompt dependencies that this catalog cannot satisfy. """Declared prompt dependencies that this catalog cannot satisfy.
@ -737,6 +810,7 @@ def cmd_add(args: argparse.Namespace) -> None:
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"])
report_skipped(copy_package(src.resolve(), dst, manifest, "catalog package")) report_skipped(copy_package(src.resolve(), dst, manifest, "catalog package"))
record_inclusion(catalog, registry, manifest, str(src.resolve()), "add")
report_missing_dependencies(catalog, manifest) report_missing_dependencies(catalog, manifest)
print(f"added {registry}:{manifest['id']}@{manifest['version']} -> {dst}") print(f"added {registry}:{manifest['id']}@{manifest['version']} -> {dst}")
@ -759,6 +833,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"])
report_skipped(copy_package(src.resolve(), dst, manifest, "published package")) report_skipped(copy_package(src.resolve(), dst, manifest, "published package"))
record_inclusion(registry, name or "", manifest, str(src.resolve()), "publish")
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}")
@ -778,6 +853,7 @@ def cmd_install(args: argparse.Namespace) -> None:
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_package(src, dst, manifest, "catalog package") copy_package(src, dst, manifest, "catalog package")
record_inclusion(catalog, name, manifest, str(registry.resolve()), "install")
report_missing_dependencies(catalog, manifest) report_missing_dependencies(catalog, manifest)
print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}") print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}")
@ -1238,6 +1314,28 @@ def cmd_eval(args: argparse.Namespace) -> None:
raise CannedPromptError(f"{failures} render check(s) failed") raise CannedPromptError(f"{failures} render check(s) failed")
def cmd_index(args: argparse.Namespace) -> None:
store = Path(args.catalog).expanduser()
entries = read_index(store)["entries"]
if args.id:
_, package_id = parse_reference(args.id)
entries = [e for e in entries if e.get("id") == package_id]
if args.json:
print(json.dumps(entries, indent=2, ensure_ascii=False))
return
if not entries:
print("no packages recorded in this catalog")
return
width = max(len(f"{e.get('registry')}:{e.get('id')}@{e.get('version')}") for e in entries)
for entry in entries:
label = f"{entry.get('registry')}:{entry.get('id')}@{entry.get('version')}"
print(f"{label:<{width}} {entry.get('included_at')} {entry.get('method')}")
print(f"{'':<{width}} from {entry.get('source')}")
declared = entry.get("declared_source")
if declared:
print(f"{'':<{width}} declares source {declared}")
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="canned-prompts", description=__doc__) parser = argparse.ArgumentParser(prog="canned-prompts", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@ -1274,6 +1372,12 @@ def build_parser() -> argparse.ArgumentParser:
render.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE") render.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
render.set_defaults(func=cmd_render) render.set_defaults(func=cmd_render)
index = sub.add_parser("index", help="show what entered this catalog, from where and when")
index.add_argument("id", nargs="?")
index.add_argument("--catalog", default=str(default_catalog()))
index.add_argument("--json", action="store_true")
index.set_defaults(func=cmd_index)
evaluate = sub.add_parser("eval", help="run an installed package's render checks") evaluate = sub.add_parser("eval", help="run an installed package's render checks")
evaluate.add_argument("id") evaluate.add_argument("id")
evaluate.add_argument("--version") evaluate.add_argument("--version")

View file

@ -808,3 +808,62 @@ def test_dependency_present_but_wrong_version_is_reported(composed: Path) -> Non
manifest = cp.validate_package(package_dir) manifest = cp.validate_package(package_dir)
manifest["dependencies"]["prompts"][0]["version"] = "9.9.9" manifest["dependencies"]["prompts"][0]["version"] = "9.9.9"
assert cp.missing_dependencies(composed, manifest) == ["style/house@9.9.9"] assert cp.missing_dependencies(composed, manifest) == ["style/house@9.9.9"]
# --- catalog index (§ 20.3) ---
def indexed(tmp_path: Path) -> tuple[Path, dict]:
store = tmp_path / "catalog"
manifest = {"id": "helix/thing", "version": "1.0.0", "name": "Thing",
"license": "MIT", "provenance": {"author": "Ada", "source": "~/somewhere"}}
return store, manifest
def test_index_starts_empty(tmp_path: Path) -> None:
store, _ = indexed(tmp_path)
assert cp.read_index(store)["entries"] == []
def test_record_inclusion_captures_source_and_date(tmp_path: Path) -> None:
store, manifest = indexed(tmp_path)
entry = cp.record_inclusion(store, "local", manifest, "/src/thing", "add")
assert entry["id"] == "helix/thing"
assert entry["source"] == "/src/thing"
assert entry["method"] == "add"
assert entry["declared_author"] == "Ada"
assert entry["declared_source"] == "~/somewhere"
assert entry["license"] == "MIT"
assert entry["included_at"].endswith("Z")
assert cp.read_index(store)["entries"] == [entry]
def test_reincluding_keeps_the_original_date(tmp_path: Path) -> None:
"""When a package first entered is a fact about history, not about reruns."""
store, manifest = indexed(tmp_path)
first = cp.record_inclusion(store, "local", manifest, "/src/thing", "add")
again = cp.record_inclusion(store, "local", manifest, "/elsewhere", "install")
assert again["included_at"] == first["included_at"]
assert "last_seen_at" in again
assert len(cp.read_index(store)["entries"]) == 1
def test_distinct_versions_are_separate_entries(tmp_path: Path) -> None:
store, manifest = indexed(tmp_path)
cp.record_inclusion(store, "local", manifest, "/src", "add")
cp.record_inclusion(store, "local", {**manifest, "version": "1.1.0"}, "/src", "add")
assert len(cp.read_index(store)["entries"]) == 2
def test_same_id_from_two_registries_is_two_entries(tmp_path: Path) -> None:
store, manifest = indexed(tmp_path)
cp.record_inclusion(store, "local", manifest, "/a", "add")
cp.record_inclusion(store, "house", manifest, "/b", "install")
assert len(cp.read_index(store)["entries"]) == 2
def test_bad_index_format_is_rejected(tmp_path: Path) -> None:
store, _ = indexed(tmp_path)
store.mkdir(parents=True)
(store / "index.yaml").write_text("format: something/else\nentries: []\n", encoding="utf-8")
with pytest.raises(cp.CannedPromptError, match="unsupported index format"):
cp.read_index(store)

View file

@ -0,0 +1,105 @@
---
id: CANP-WP-0004
type: workplan
title: "Catalog index: where a package came from and when"
domain: agents
repo: canned-prompts
status: finished
owner: codex
topic_slug: practice
created: "2026-09-06"
updated: "2026-09-06"
---
# Catalog index: where a package came from and when
Driven by packaging a real collection (`helix-forge` `HF-WP-0005`). The operator
asked for canned-prompts to "build a database of versioned prompts", recording
the source a prompt came from and the date it was included — the first step
toward a platform for collaborative prompting.
The format had no place for this. `provenance` (§ 13) records who wrote a prompt
and where the idea came from, but nothing recorded how a *copy* arrived in a
*particular* store.
## Fix practice/pqrst-estimate to be the canonical prompt
```task
id: CANP-WP-0004-T01
status: done
priority: high
```
**Found while researching helix-forge.** `examples/pqrst-estimate` was not the
canonical PQRST prompt. The canonical one is `~/pqrst-practice/PqrstPrompt.md`,
normatively specified in `spec/PqrstEstimationPractice.md`, and
`hall-of-helix/CLOSING.md` requires pasting it unmodified. Ours was a paraphrase
with a different output shape — no `Confidence`, no `Signature`, no `Dominant
factors` — and `CANP-WP-0002-T03` had made it worse by prepending a
`house_style` inclusion to a prompt whose governing document says do not modify
it.
A copied prompt that silently forked its source, sitting in the examples
directory of the repo whose `INTENT.md` opens by naming that exact failure.
Fixed: the template is the canonical block extracted programmatically rather
than retyped, and the default render is byte-identical at 2886 bytes.
`evals/canonical-fidelity.yaml` guards it with sixteen render checks, including
`not_contains` checks naming the paraphrase it used to be. Version 0.2.1 →
1.0.0, the § 17 MAJOR case.
The source documents two optional add-ons appended after the block. CPF has no
conditionals, so `add_ons` is an input defaulting to the empty string and each
add-on is an example fixture. Adequate, but a workaround — recorded as evidence
about § 23's deferred "richer template syntax".
## Add the index
```task
id: CANP-WP-0004-T02
status: done
priority: high
```
A store may keep an `index.yaml` recording which package versions entered it,
from where, and when. Specified in § 20.3, deliberately as **store** metadata
rather than package data: how a copy arrived differs for every consumer, and
recording an arrival must never rewrite the package that arrived (§ 17).
`add`, `install` and `publish` record an entry carrying registry, id, version,
name, source, method, `included_at`, the package's declared author and source,
and its licence — the last two copied so a listing is readable without opening
every package. A new `index` verb lists it.
`included_at` is first arrival and is never overwritten; a re-run updates
`last_seen_at` instead. When a package first entered a collection is a fact
about history, not about the last time someone ran a command.
`reference/canned_prompts.py`: `read_index`, `record_inclusion`,
`index_entry_key`, `cmd_index`. Tests 84 → 90.
## Record the inclusion-deduplication gap
```task
id: CANP-WP-0004-T03
status: done
priority: medium
```
**Found by using the format.** `helix/repo-advance` composed
`helix/commit-sync`, which composes `helix/custodian-conventions`, while also
composing the conventions itself. The rendered prompt contained the conventions
block twice: CPF inclusion has no deduplication, so a diamond dependency renders
shared content once per path.
Not fixed in the format. Deduplicating would mean deciding *which* occurrence
survives and what happens when the two paths resolve different versions — that
is resolver behaviour, and § 10.4 keeps composition declarative on purpose.
Nothing in § 10.4 currently warns an author, which is the actual defect.
Downstream this was fixed by factoring (`HF-WP-0005-T03`), which is the right
answer for a collection and may be the right general advice.
**Handed to `CANP-WP-0005`:** document the diamond behaviour in § 10.4 and
decide whether a validator should warn when one package reaches the same
dependency by two paths.

View file

@ -0,0 +1,47 @@
---
id: CANP-WP-0005
type: workplan
title: "Document and detect inclusion diamonds"
domain: agents
repo: canned-prompts
status: proposed
owner: codex
topic_slug: practice
created: "2026-09-06"
updated: "2026-09-06"
---
# Document and detect inclusion diamonds
Residual from `CANP-WP-0004` (`origin: residual`, `origin_ref: CANP-WP-0004`).
## Warn about repeated inclusion
```task
id: CANP-WP-0005-T01
status: todo
priority: medium
```
CPF inclusion (§ 10.4) does not deduplicate: when one package reaches the same
dependency by two paths, the included text renders once per path. This was found
by building a real collection — `helix/repo-advance` rendered its conventions
block twice — and worked around downstream by factoring the shared routine into
its own fragment.
§ 10.4 says nothing about it, so an author meets the behaviour only by reading
the output carefully. That silence is the defect.
Two questions, and the second depends on the first:
1. **Document it.** State in § 10.4 that inclusion is textual and repeated,
not deduplicated, and give the factoring pattern that avoids a diamond.
2. **Detect it.** Decide whether validation should warn when a package reaches
the same dependency by more than one path. A warning is cheap and catches the
mistake at `add` time. Silently deduplicating is a different proposition
entirely — it would mean choosing which occurrence survives, and deciding what
happens when two paths resolve *different versions* of the same dependency.
That is resolver behaviour, and § 10.4 keeps composition declarative on
purpose.
Leaning: document it now, warn at validation time, and do not deduplicate.