Tasks — US2: Prompt Versioning via Frontmatter (Priority: P1)¶
Feature: 031-eval-runs-dashboard
Spec: spec.md — User Story 2 (P1)
Plan: plan.md
Data model: data-model.md — PromptVersion
Research: research.md — R1
Goal: Parse optional YAML frontmatter from the instructions markdown file via python-frontmatter. Expose a PromptVersion Pydantic model capturing version (manual or auto-<sha256[:8]>), author, description, tags, source, file_path, body_hash, and extra (unknown keys). The prompt body presented to the LLM MUST be identical to today (frontmatter-stripped).
Independent Test: Create instructions.md with version: "1.2" frontmatter → run holodeck test → EvalRun.metadata.prompt_version.version == "1.2". Remove the manual version → re-run → version becomes "auto-" + first 8 hex chars of SHA-256(body) and is stable across reruns with unchanged body.
TDD discipline: Every task marked "(TDD)" writes the failing test first. Do not implement ahead.
Dependency: US1 must have a PromptVersion stub in src/holodeck/models/eval_run.py (US1 T024). US2 replaces stub fields with full validation and provides the resolve_prompt_version() function US1 T032 calls.
Phase 1: Setup¶
- T101 [US2] Add
python-frontmatter>=1.1,<2.0to core dependencies inpyproject.toml; runuv lockand commit the updated lockfile - T102 [US2] Verify
frontmatterimports cleanly in.venvby runningpython -c "import frontmatter; print(frontmatter.__version__)"
Phase 2: Foundational¶
None. US2 is self-contained once the dep is installed.
Phase 3: US2 — PromptVersion model + resolver¶
Tests first (TDD)¶
- T103 [P] [US2] (TDD)
tests/unit/models/test_prompt_version.py: model field coverage —version: strrequired,author: str | None,description: str | None,tags: list[str]default[],source: Literal["file", "inline"]required,file_path: str | None,body_hash: strrequired (matches^[a-f0-9]{64}$),extra: dict[str, Any]default{};extra="forbid"on the model itself - T104 [P] [US2] (TDD)
tests/unit/models/test_prompt_version.py: validation —source="inline"requiresfile_path is None;source="file"requiresfile_pathnon-empty; invalidbody_hash(wrong length, non-hex) fails validation - T105 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py:resolve_prompt_version(instructions, base_dir)— wheninstructions.inlineis set, returnssource="inline",file_path=None,version=f"auto-{sha256(inline)[:8]}",body_hashis full 64-char SHA-256 of inline content, frontmatter parsing is skipped (FR-014) - T106 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py: given a fixtureinstructions.mdwith frontmatterversion: "1.2",author: "jane",description: "d",tags: [a, b]— returns those values verbatim;source="file",file_pathis the resolved path,body_hashis SHA-256 of content AFTER frontmatter removal (post.content) (FR-011, AC1) - T107 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py: given frontmatter without aversion:key,version == "auto-" + sha256(body)[:8]and stays identical across two calls with the same file content (FR-013, AC2, SC-003) - T108 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py: given a file with NO frontmatter at all, no error is raised;version == auto-hash,author is None,description is None,tags == [],extra == {}(FR-011, AC3) - T109 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py: mutating one character of the prompt body changesversion(SC-004, AC5) - T110 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py: unknown frontmatter keys (custom_field: value,another: 42) are preserved inPromptVersion.extra, recognised keys are NOT duplicated intoextra(FR-016, AC7) - T111 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py: malformed YAML inside---fences (e.g.,tags: [unclosed) raisesConfigErrorreferencing the file path and YAML parse error (FR-017, edge case) - T112 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py: relativeinstructions.filepaths are resolved against the providedbase_dir(matches existingresolve_instructionsconvention) - T113 [P] [US2] (TDD)
tests/integration/cli/test_prompt_version_in_eval_run.py: end-to-end — runholodeck testagainst a fixture with frontmatter-annotated instructions, assert the persistedEvalRun.metadata.prompt_version.version == "1.2"andtags == ["support", "v1"] - T114 [P] [US2] (TDD)
tests/unit/lib/test_prompt_version.py: assert the body returned by the existingresolve_instructions()is UNCHANGED by this feature —resolve_instructionsreturns the same full string it always did (including or excluding frontmatter per pre-existing behaviour). Document the invariant:resolve_prompt_versionis a SEPARATE call;resolve_instructionssignature is untouched (research.md R1, plan.md §"Key architecture decisions") - T115 [P] [US2] (TDD)
tests/integration/cli/test_prompt_body_unchanged.py: snapshot the exact string passed to the LLM (intercept via SK/Claude backend spy) before and after this feature lands — byte-equivalent (FR-015)
Implementation¶
- T116 [US2] Replace the
PromptVersionstub insrc/holodeck/models/eval_run.pywith the full model per data-model.md: all fields, validators (body_hashregex,source/file_pathconsistency),extra="forbid"at Pydantic level (unknown YAML keys land in theextradict, not as extra model fields) - T117 [US2] Create
src/holodeck/lib/prompt_version.pyexposingresolve_prompt_version(instructions: Instructions, base_dir: Path | None) -> PromptVersion - T118 [US2] Inline branch in
resolve_prompt_version: wheninstructions.inlineis set, skip frontmatter entirely; compute SHA-256 ofinstructions.inline; returnPromptVersion(source="inline", file_path=None, version=f"auto-{hash[:8]}", body_hash=hash, tags=[], extra={}) - T119 [US2] File branch in
resolve_prompt_version: resolveinstructions.fileagainstbase_dir(matchresolve_instructionsconvention); callfrontmatter.load(resolved_path); wrapyaml.YAMLError→ raiseConfigError("instructions.file", f"Malformed YAML frontmatter in {path}: {e}")(FR-017) - T120 [US2] Partition parsed metadata in
resolve_prompt_version: splitpost.metadatainto recognised keys (version,author,description,tags) vs. the remainder (→extra); computebody_hash = sha256(post.content.encode("utf-8")).hexdigest(); deriveversion = metadata.get("version", f"auto-{body_hash[:8]}") - T121 [US2] Export
resolve_prompt_versionandPromptVersionfromsrc/holodeck/lib/prompt_version.pyand re-exportPromptVersionfromsrc/holodeck/models/__init__.pyfor discoverability - T122 [US2] Update US1 T032 wiring in
src/holodeck/cli/commands/test.py(or wherever US1 assembledEvalRun) to callresolve_prompt_version(agent.instructions, agent_base_dir)and pass the result intobuild_eval_run_metadata(...)— replacing the US1 stub - T123 [US2] Verify
resolve_instructionsinsrc/holodeck/lib/instruction_resolver.pyis UNCHANGED (no signature change); confirm no other call site imports or referencesresolve_prompt_version(additive module, per plan.md §"Key architecture decisions")
Checkpoint: All US2 tests green. Quickstart §3 reproducible: removing the manual version: yields a stable auto-<hash> across reruns; one-char body edit changes the hash.
Dependencies¶
- T101 blocks T102–T123 (dep must be installed).
- T103–T115 (TDD tests) must be failing before T116–T123 implementation.
- T116 depends on US1 T024 (PromptVersion stub location exists).
- T117–T120 depend on T116 (need the full model).
- T122 depends on US1 T030–T031 (CLI wiring present) and on T117–T121.
Parallel Opportunities¶
# TDD phase — all test files are independent:
Task: "tests/unit/models/test_prompt_version.py (T103, T104)"
Task: "tests/unit/lib/test_prompt_version.py (T105–T112, T114)"
Task: "tests/integration/cli/test_prompt_version_in_eval_run.py (T113)"
Task: "tests/integration/cli/test_prompt_body_unchanged.py (T115)"
# Implementation — leaf module independent of US1:
Task: "src/holodeck/lib/prompt_version.py (T117–T120)"
Acceptance Scenario Traceability¶
| AC | Covered by |
|---|---|
AC1 (version: "1.2" from frontmatter) |
T106, T113 |
AC2 (no manual version: → auto-hash) |
T107 |
| AC3 (no frontmatter → no error, auto-hash) | T108 |
| AC4 (stable hash when body unchanged, SC-003) | T107 |
| AC5 (one-char edit changes hash, SC-004) | T109 |
| AC6 (inline instructions → auto-hash) | T105 |
AC7 (unknown keys → extra) |
T110 |
| FR-015 (body to LLM unchanged) | T114, T115 |
FR-017 (malformed YAML → ConfigError) |
T111 |
Implementation Strategy¶
- Install the dep first (T101).
- Write every TDD test (T103–T115); confirm all fail.
- Replace the US1
PromptVersionstub with the full model (T116). - Implement
resolve_prompt_versionleaf-first (inline branch → file branch → metadata partition → error wrapping). - Wire into CLI (T122); remove US1's stub call.
- Verify quickstart §3 by hand.