propresenter-php/.sisyphus/plans/proplaylist-module.md
Thorsten Bus 2bf1eff12d chore(plan): mark all 58 acceptance criteria as complete
All acceptance criteria verified and marked complete:
- Task 1-13: Implementation acceptance criteria (all verified)
- Definition of Done: All 5 criteria met
- Final Verification: All 4 phases approved
- Final Checklist: All 7 items complete

Total: 87/87 checkboxes complete (29 main tasks + 58 acceptance criteria)
2026-03-01 21:51:52 +01:00

1507 lines
74 KiB
Markdown

# ProPresenter .proplaylist PHP Module
## TL;DR
> **Quick Summary**: Build a complete PHP module for reading, writing, and generating ProPresenter 7 `.proplaylist` files. The format is a ZIP64 archive containing protobuf-encoded playlist metadata (`data` file), embedded `.pro` song files, and media files. Extends the existing `php/src/` parser codebase following the same static-factory + protobuf-wrapper patterns.
>
> **Deliverables**:
> - Proto modification: add undocumented `arrangement_name` field 5 + regenerate PHP classes
> - ZIP64 patching utility: fix ProPresenter's broken ZIP headers for PHP ZipArchive compatibility
> - 3 wrapper classes: `PlaylistArchive`, `PlaylistNode`, `PlaylistEntry`
> - Reader: `ProPlaylistReader` — reads .proplaylist ZIP → wrapper objects
> - Writer: `ProPlaylistWriter` — serializes wrapper objects → .proplaylist ZIP
> - Generator: `ProPlaylistGenerator` — creates playlists from scratch
> - CLI tool: `php/bin/parse-playlist.php` — inspect playlist structure
> - Spec document: `spec/pp_playlist_spec.md` — format documentation
> - Full PHPUnit test suite: TDD for all components
>
> **Estimated Effort**: Large
> **Parallel Execution**: YES — 4 waves
> **Critical Path**: Task 1 → Task 2 → Task 4 → Task 7 → Task 9 → Task 11 → Task 14
---
## Context
### Original Request
User wants to extend the existing ProPresenter PHP parser to support `.proplaylist` files — ZIP archives that bundle playlist metadata, `.pro` song files, and media files. This is a completely undocumented format that was reverse-engineered from 4 real files.
### Interview Summary
**Key Discussions**:
- **Scope**: Full Read + Write + Generate support
- **Testing**: TDD with PHPUnit (existing infrastructure: PHPUnit 11 with `#[Test]` attributes)
- **Field 5 handling**: Extend proto locally — add `string arrangement_name = 5;` to `PlaylistItem.Presentation`
- **Writer ZIP quirk**: Write clean, standard-compliant ZIP (don't reproduce PP's 98-byte quirk)
- **Embedded .pro parsing**: Raw bytes + lazy parsing — store bytes, provide `getEmbeddedSong()` on demand
**Research Findings**:
- No public documentation exists for .proplaylist format
- Format is ZIP64 with `store` compression; `data` file contains protobuf `PlaylistDocument`
- ProPresenter exports ZIP with consistently broken Central Directory size fields (off by 98 bytes)
- PHP's ZipArchive rejects these files (error 21 = ER_INCONS); need patching before reading
- Undocumented field 5 on `PlaylistItem.Presentation` stores arrangement name as string
- All proto classes already generated in `php/generated/Rv/Data/`
- URL paths use `ROOT_USER_HOME (2)` for personal paths, `ROOT_SHOW (10)` for PP library paths
### Metis Review
**Identified Gaps** (addressed):
- Wrapper class naming collision: Use `PlaylistArchive`, `PlaylistNode`, `PlaylistEntry` (not `Playlist`/`PlaylistItem`)
- Proto regeneration: Must regenerate after adding field 5 — verified existing Makefile/script patterns
- ZIP64 patching: Must patch both ZIP64 EOCD (offset +40) and regular EOCD (offset +12) — validated algorithm
- Test data paths: Use `dirname(__DIR__, 2) . '/ref/...'` pattern matching existing tests
---
## Work Objectives
### Core Objective
Implement a complete, tested PHP module that can read, write, and generate ProPresenter 7 `.proplaylist` files, following the same architecture patterns as the existing `.pro` file parser.
### Concrete Deliverables
- Modified `php/proto/playlist.proto` with field 5 added
- Regenerated PHP classes in `php/generated/`
- `php/src/Zip64Fixer.php` — ZIP64 header patching utility
- `php/src/PlaylistArchive.php` — top-level playlist wrapper (like `Song.php`)
- `php/src/PlaylistNode.php` — playlist/folder node wrapper (like `Group.php`)
- `php/src/PlaylistEntry.php` — playlist item wrapper (header/presentation/placeholder/cue)
- `php/src/ProPlaylistReader.php` — reads .proplaylist files (like `ProFileReader.php`)
- `php/src/ProPlaylistWriter.php` — writes .proplaylist files (like `ProFileWriter.php`)
- `php/src/ProPlaylistGenerator.php` — generates playlists from scratch (like `ProFileGenerator.php`)
- `php/bin/parse-playlist.php` — CLI tool (like `php/bin/parse-song.php`)
- `spec/pp_playlist_spec.md` — format specification (like `spec/pp_song_spec.md`)
- `php/tests/Zip64FixerTest.php` — tests for ZIP patching
- `php/tests/PlaylistArchiveTest.php` — tests for wrapper class
- `php/tests/PlaylistNodeTest.php` — tests for node wrapper
- `php/tests/PlaylistEntryTest.php` — tests for entry wrapper
- `php/tests/ProPlaylistReaderTest.php` — tests for reader
- `php/tests/ProPlaylistWriterTest.php` — tests for writer
- `php/tests/ProPlaylistGeneratorTest.php` — tests for generator
### Definition of Done
- [x] `php vendor/bin/phpunit` from `php/` directory — ALL tests pass (0 failures, 0 errors)
- [x] `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` — outputs structured playlist data
- [x] `php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist` — handles large real-world file
- [x] Round-trip: read → write → read produces identical data
- [x] Generated playlist can be read back by the reader
### Must Have
- ZIP64 patching that handles ProPresenter's broken archives
- All 4 PlaylistItem types: header, presentation, placeholder, cue
- Arrangement name (field 5) read/write support
- Embedded .pro file access (raw bytes + lazy Song parsing)
- Media file access from ZIP
- All URL root types (ROOT_USER_HOME, ROOT_SHOW)
- CLI tool with structured output matching parse-song.php style
- Format specification document
### Must NOT Have (Guardrails)
- Do NOT modify generated proto PHP classes directly — only modify `.proto` source and regenerate
- Do NOT name wrapper classes `Playlist` or `PlaylistItem` — collision with `Rv\Data\Playlist` / `Rv\Data\PlaylistItem`
- Do NOT auto-parse embedded .pro files during read — use lazy loading only
- Do NOT reproduce ProPresenter's 98-byte ZIP quirk in writer — write clean, standard ZIP
- Do NOT add audio playlist support (TYPE_AUDIO) — out of scope
- Do NOT add PlanningCenter integration — out of scope
- Do NOT add smart directory support — out of scope
- Do NOT create abstract base classes or over-abstract — follow the flat, concrete style of existing code
---
## Verification Strategy
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
### Test Decision
- **Infrastructure exists**: YES
- **Automated tests**: TDD (RED → GREEN → REFACTOR)
- **Framework**: PHPUnit 11 with `#[Test]` attributes
- **Each task**: Write failing test first, then implement until tests pass
### QA Policy
Every task MUST include agent-executed QA scenarios.
Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`.
- **PHP Module**: Use Bash — `php vendor/bin/phpunit --filter ClassName` or inline PHP execution
- **CLI Tool**: Use Bash — run CLI command, verify output format
- **Proto Regen**: Use Bash — verify classes exist and contain expected methods
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Foundation — start immediately, all independent):
├── Task 1: Proto modification + regeneration [quick]
├── Task 2: Zip64Fixer utility (TDD) [deep]
└── Task 3: Format specification document [writing]
Wave 2 (Wrapper classes — after Wave 1, all parallel):
├── Task 4: PlaylistEntry wrapper (TDD) [unspecified-high]
├── Task 5: PlaylistNode wrapper (TDD) [unspecified-high]
└── Task 6: PlaylistArchive wrapper (TDD) [unspecified-high]
Wave 3 (Reader/Writer/Generator — after Wave 2):
├── Task 7: ProPlaylistReader (TDD) [deep]
├── Task 8: ProPlaylistWriter (TDD) [deep]
├── Task 9: ProPlaylistGenerator (TDD) [deep]
└── Task 10: CLI tool parse-playlist.php [quick]
Wave 4 (Integration + verification — after Wave 3):
├── Task 11: Round-trip integration tests [deep]
├── Task 12: Large-file / real-world validation [unspecified-high]
└── Task 13: AGENTS.md documentation update [quick]
Wave FINAL (After ALL tasks — independent review, 4 parallel):
├── Task F1: Plan compliance audit (oracle)
├── Task F2: Code quality review (unspecified-high)
├── Task F3: Real manual QA (unspecified-high)
└── Task F4: Scope fidelity check (deep)
Critical Path: Task 1 → Task 2 → Task 4 → Task 7 → Task 9 → Task 11 → F1-F4
Parallel Speedup: ~60% faster than sequential
Max Concurrent: 3 (Waves 1, 2, 3)
```
### Dependency Matrix
| Task | Depends On | Blocks | Wave |
|------|-----------|--------|------|
| 1 | — | 4, 5, 6 | 1 |
| 2 | — | 7, 8 | 1 |
| 3 | — | — | 1 |
| 4 | 1 | 5, 6, 7, 8, 9 | 2 |
| 5 | 1, 4 | 6, 7, 8, 9 | 2 |
| 6 | 1, 4, 5 | 7, 8, 9, 10 | 2 |
| 7 | 2, 6 | 8, 10, 11, 12 | 3 |
| 8 | 6, 7 | 11, 12 | 3 |
| 9 | 6 | 11 | 3 |
| 10 | 7 | 12 | 3 |
| 11 | 7, 8, 9 | — | 4 |
| 12 | 7, 10 | — | 4 |
| 13 | 7, 10 | — | 4 |
| F1-F4 | ALL | — | FINAL |
### Agent Dispatch Summary
- **Wave 1**: 3 tasks — T1 → `quick`, T2 → `deep`, T3 → `writing`
- **Wave 2**: 3 tasks — T4 → `unspecified-high`, T5 → `unspecified-high`, T6 → `unspecified-high`
- **Wave 3**: 4 tasks — T7 → `deep`, T8 → `deep`, T9 → `deep`, T10 → `quick`
- **Wave 4**: 3 tasks — T11 → `deep`, T12 → `unspecified-high`, T13 → `quick`
- **FINAL**: 4 tasks — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep`
---
## TODOs
> Implementation + Test = ONE Task. Never separate.
> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.
- [x] 1. Proto modification: Add arrangement_name field 5 + regenerate PHP classes
**What to do**:
- Edit `php/proto/playlist.proto`: in the `PlaylistItem.Presentation` message (line ~89-94), add `string arrangement_name = 5;` after the `user_music_key` field
- Regenerate PHP protobuf classes by running `protoc` with the same flags used for the existing generation. Check if there's a Makefile or shell script in `php/` or project root. If not, run:
```
protoc --php_out=php/generated --proto_path=php/proto php/proto/*.proto
```
- Verify the regenerated `php/generated/Rv/Data/PlaylistItem/Presentation.php` now has `getArrangementName()` and `setArrangementName()` methods
- Run existing tests to ensure nothing broke: `cd php && php vendor/bin/phpunit`
**Must NOT do**:
- Do NOT manually edit any file in `php/generated/` — only modify `.proto` source
- Do NOT change any existing field numbers or types
- Do NOT add fields beyond `arrangement_name = 5`
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Single proto file edit + running a command to regenerate
- **Skills**: []
- No special skills needed — file edit + bash command
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Tasks 2, 3)
- **Blocks**: Tasks 4, 5, 6 (wrapper classes need the updated proto)
- **Blocked By**: None (can start immediately)
**References**:
**Pattern References**:
- `php/proto/playlist.proto:89-94` — The `PlaylistItem.Presentation` message where field 5 must be added. Currently has fields 1-4 (document_path, arrangement, content_destination, user_music_key)
- `php/generated/Rv/Data/PlaylistItem/Presentation.php` — The generated class that will be regenerated. Currently has getters/setters for fields 1-4 only
**API/Type References**:
- `php/generated/Rv/Data/PlaylistDocument.php` — Top-level message class (should NOT change)
- `php/generated/Rv/Data/PlaylistItem.php` — Parent class with oneof ItemType (should gain no new fields)
**External References**:
- Protobuf proto3 syntax: field 5 is `string arrangement_name = 5;`
**WHY Each Reference Matters**:
- `playlist.proto:89-94`: This is the EXACT location to add the field. The `PlaylistItem.Presentation` message currently ends at field 4. Field 5 is undocumented in the community proto but observed in every real-world .proplaylist file holding an arrangement name string like "normal", "bene", "test2"
- The generated Presentation.php: After regeneration, verify this file has the new methods — that's the acceptance criterion
**Acceptance Criteria**:
- [x] `php/proto/playlist.proto` contains `string arrangement_name = 5;` inside `PlaylistItem.Presentation` message
- [x] `php/generated/Rv/Data/PlaylistItem/Presentation.php` contains methods `getArrangementName()` and `setArrangementName()`
- [x] `cd php && php vendor/bin/phpunit` — ALL existing tests still pass (0 failures)
**QA Scenarios:**
```
Scenario: Proto field 5 present in source
Tool: Bash (grep)
Preconditions: Task 1 complete
Steps:
1. grep 'arrangement_name' php/proto/playlist.proto
2. Assert output contains 'string arrangement_name = 5;'
Expected Result: Exactly one match in PlaylistItem.Presentation message
Failure Indicators: No match, or field number != 5
Evidence: .sisyphus/evidence/task-1-proto-field.txt
Scenario: Generated PHP class has new methods
Tool: Bash (grep)
Preconditions: Proto regeneration complete
Steps:
1. grep -c 'getArrangementName\|setArrangementName' php/generated/Rv/Data/PlaylistItem/Presentation.php
2. Assert count >= 2
Expected Result: At least 2 matches (getter + setter)
Failure Indicators: 0 matches means regeneration failed or field not added
Evidence: .sisyphus/evidence/task-1-generated-methods.txt
Scenario: Existing tests still pass
Tool: Bash
Preconditions: Regeneration complete
Steps:
1. cd php && php vendor/bin/phpunit
2. Assert exit code 0
3. Assert output contains 'OK'
Expected Result: All existing tests pass with 0 failures
Failure Indicators: Any test failure or error
Evidence: .sisyphus/evidence/task-1-existing-tests.txt
```
**Commit**: YES
- Message: `feat(proto): add arrangement_name field 5 to PlaylistItem.Presentation`
- Files: `php/proto/playlist.proto`, `php/generated/**`
- Pre-commit: `cd php && php vendor/bin/phpunit`
- [x] 2. Zip64Fixer utility (TDD)
**What to do**:
- **RED**: Write `php/tests/Zip64FixerTest.php` first with these test cases:
- `fixReturnsValidZipForProPresenterExport`: Pass `ref/TestPlaylist.proplaylist` bytes → output should open with `ZipArchive::open()` without errors
- `fixReturnsValidZipForLargePlaylist`: Pass `ref/ExamplePlaylists/Gottesdienst.proplaylist` → same
- `fixThrowsOnNonZipData`: Pass random bytes → expect RuntimeException
- `fixThrowsOnTooSmallData`: Pass 10 bytes → expect RuntimeException
- `fixPreservesAllEntries`: After fix, ZipArchive lists same entries as `unzip -l`
- `fixIdempotent`: Fixing an already-fixed file produces identical output
- **GREEN**: Implement `php/src/Zip64Fixer.php` with a single static method:
```php
final class Zip64Fixer {
public static function fix(string $zipData): string
}
```
- Algorithm:
1. Find EOCD signature (`\x50\x4b\x05\x06`) scanning from end of file
2. Read CD offset from EOCD (offset +16, 4 bytes, little-endian)
3. If CD offset == 0xFFFFFFFF, find ZIP64 EOCD locator (signature `\x50\x4b\x06\x07`), then ZIP64 EOCD (signature `\x50\x4b\x06\x06`)
4. From ZIP64 EOCD: read CD offset (offset +48, 8 bytes LE) and zip64_eocd_position
5. Calculate `correct_cd_size = zip64_eocd_position - cd_offset`
6. Patch ZIP64 EOCD field at offset +40 (8 bytes LE) with correct_cd_size
7. Patch regular EOCD field at offset +12 (4 bytes LE) with `min(correct_cd_size, 0xFFFFFFFF)`
8. Return patched bytes
- **REFACTOR**: Extract constants for magic numbers, add doc comments
**Must NOT do**:
- Do NOT modify the original file on disk — work on bytes in memory
- Do NOT decompress/recompress — only patch header fields
- Do NOT use external ZIP repair tools
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: Binary format parsing requires careful byte-level work and thorough testing
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Tasks 1, 3)
- **Blocks**: Tasks 7, 8 (Reader/Writer need the fixer)
- **Blocked By**: None (can start immediately)
**References**:
**Pattern References**:
- `php/src/ProFileReader.php:13-37` — Static factory pattern with validation/error handling. Follow the same `InvalidArgumentException`/`RuntimeException` pattern
- `php/tests/ProFileReaderTest.php` — Test structure with `#[Test]` attributes, `dirname(__DIR__, 2)` for test data paths
**API/Type References**:
- PHP `ZipArchive` class — Used to verify the fix works. `ZipArchive::open()` returns `true` on success, error code on failure. Error 21 = `ER_INCONS` (the broken state)
**External References**:
- ZIP64 format: EOCD at end of file, signature `0x06054b50`. ZIP64 EOCD signature `0x06064b50`. ZIP64 EOCD locator `0x07064b50`
- The 98-byte discrepancy: ProPresenter writes CD size field as `actual_cd_size + 98` consistently across all exported files
**WHY Each Reference Matters**:
- `ProFileReader.php`: Shows the exact error handling pattern (exception types, message formatting) to replicate
- `ProFileReaderTest.php`: Shows the exact test structure (namespace, imports, `#[Test]` attribute, `dirname(__DIR__, 2)` for paths) to replicate
- ZIP64 specification: The byte offsets and signatures are critical — getting any offset wrong by 1 byte breaks everything
**Test Data**:
- `ref/TestPlaylist.proplaylist` — small file, fast to test
- `ref/ExamplePlaylists/Gottesdienst.proplaylist` — 14MB real-world file
**Acceptance Criteria**:
- [x] `php/tests/Zip64FixerTest.php` exists with ≥6 test methods
- [x] `php/src/Zip64Fixer.php` exists with `fix(string $zipData): string` static method
- [x] `cd php && php vendor/bin/phpunit --filter Zip64FixerTest` — ALL tests pass
- [x] Fixed ZIP data opens with `new ZipArchive()` without errors for all 4 test files
**QA Scenarios:**
```
Scenario: Fix and open TestPlaylist
Tool: Bash
Preconditions: Zip64Fixer implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$data = file_get_contents('ref/TestPlaylist.proplaylist');
\$fixed = ProPresenter\Parser\Zip64Fixer::fix(\$data);
\$tmp = tempnam(sys_get_temp_dir(), 'zip');
file_put_contents(\$tmp, \$fixed);
\$za = new ZipArchive();
\$result = \$za->open(\$tmp);
echo \$result === true ? 'OK' : 'FAIL: ' . \$result;
unlink(\$tmp);
"
2. Assert output is 'OK'
Expected Result: ZipArchive opens without error
Failure Indicators: Output contains 'FAIL' or any error code
Evidence: .sisyphus/evidence/task-2-fix-open.txt
Scenario: Error on invalid input
Tool: Bash
Preconditions: Zip64Fixer implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
try { ProPresenter\Parser\Zip64Fixer::fix('not a zip'); echo 'NO_EXCEPTION'; }
catch (RuntimeException \$e) { echo 'OK: ' . \$e->getMessage(); }"
2. Assert output starts with 'OK:'
Expected Result: RuntimeException thrown for invalid data
Failure Indicators: Output is 'NO_EXCEPTION'
Evidence: .sisyphus/evidence/task-2-error-handling.txt
```
**Commit**: YES
- Message: `feat(playlist): add Zip64Fixer for ProPresenter ZIP archives`
- Files: `php/src/Zip64Fixer.php`, `php/tests/Zip64FixerTest.php`
- Pre-commit: `cd php && php vendor/bin/phpunit --filter Zip64FixerTest`
- [x] 3. Format specification document
**What to do**:
- Create `spec/pp_playlist_spec.md` documenting the .proplaylist file format
- Follow the structure and style of `spec/pp_song_spec.md`
- Document:
- Container format: ZIP64 archive, store compression, ZIP64 EOCD quirk
- ZIP entry layout: `data` file at root, `.pro` files at root, media files at original paths
- Protobuf structure: `PlaylistDocument``Playlist` (root_node) → `PlaylistArray` → child `Playlist``PlaylistItems``PlaylistItem[]`
- All PlaylistItem types: Header (field 3), Presentation (field 4), Cue (field 5), PlanningCenter (field 6), Placeholder (field 8)
- Presentation details: document_path URL, arrangement UUID, arrangement_name (field 5, undocumented), user_music_key
- URL root types: ROOT_USER_HOME (2), ROOT_SHOW (10)
- Deduplication rules: same .pro stored once, media files deduplicated
- Known values: application_info, type = TYPE_PRESENTATION, root always named "PLAYLIST"
- Include concrete examples from the reverse-engineered data
**Must NOT do**:
- Do NOT include raw protobuf hex dumps — use structured descriptions
- Do NOT speculate about unobserved features — document only what was verified
**Recommended Agent Profile**:
- **Category**: `writing`
- Reason: Technical documentation task
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Tasks 1, 2)
- **Blocks**: Nothing (informational document)
- **Blocked By**: None
**References**:
**Pattern References**:
- `spec/pp_song_spec.md` — Follow the EXACT same document structure, heading hierarchy, and level of detail. This is the template for the playlist spec
**External References**:
- Draft analysis: `.sisyphus/drafts/proplaylist-format.md` — Contains all reverse-engineered findings. Copy relevant sections and restructure into spec format
- Proto definition: `php/proto/playlist.proto` — Authoritative message structure
**WHY Each Reference Matters**:
- `pp_song_spec.md`: Establishes the documentation standard for this project. The playlist spec must feel like it belongs alongside the song spec
- Draft analysis: Contains ALL the raw findings from reverse-engineering. The spec document restructures this into a clean, permanent reference
**Acceptance Criteria**:
- [x] `spec/pp_playlist_spec.md` exists
- [x] Document covers: container format, ZIP layout, protobuf structure, all item types, URL conventions
- [x] Document includes concrete examples
- [x] Follows `spec/pp_song_spec.md` style
**QA Scenarios:**
```
Scenario: Spec document exists and covers key sections
Tool: Bash (grep)
Preconditions: Task 3 complete
Steps:
1. test -f spec/pp_playlist_spec.md && echo EXISTS
2. grep -c 'PlaylistDocument\|PlaylistItem\|ZIP64\|arrangement_name\|ROOT_USER_HOME\|Header\|Presentation\|Placeholder' spec/pp_playlist_spec.md
Expected Result: File exists, grep count >= 8 (all key terms present)
Failure Indicators: File missing or key terms not mentioned
Evidence: .sisyphus/evidence/task-3-spec-coverage.txt
```
**Commit**: YES
- Message: `docs(spec): add .proplaylist format specification`
- Files: `spec/pp_playlist_spec.md`
- Pre-commit: —
- [x] 4. PlaylistEntry wrapper class (TDD)
**What to do**:
- **RED**: Write `php/tests/PlaylistEntryTest.php` first:
- `getUuid`: returns item UUID string
- `getName`: returns item name string
- `getType`: returns 'header' | 'presentation' | 'placeholder' | 'cue' | 'unknown'
- `isHeader` / `isPresentation` / `isPlaceholder` / `isCue`: boolean type checks
- For header items: `getHeaderColor()` returns `[r, g, b, a]` array
- For presentation items: `getDocumentPath()`, `getArrangementUuid()`, `getArrangementName()`, `hasArrangement()`
- For presentation items: `getDocumentFilename()` — extracts filename from URL path (e.g., `Song.pro` from full URL)
- Test with manually constructed protobuf objects (no file I/O needed)
- **GREEN**: Implement `php/src/PlaylistEntry.php`:
```php
namespace ProPresenter\Parser;
use Rv\Data\PlaylistItem;
class PlaylistEntry {
public function __construct(private readonly PlaylistItem $item) {}
public function getUuid(): string
public function getName(): string
public function getType(): string // 'header'|'presentation'|'placeholder'|'cue'|'unknown'
public function isHeader(): bool
public function isPresentation(): bool
public function isPlaceholder(): bool
public function isCue(): bool
public function getHeaderColor(): ?array // [r,g,b,a] or null
public function getDocumentPath(): ?string // absolute URL string
public function getDocumentFilename(): ?string // just the filename
public function getArrangementUuid(): ?string
public function getArrangementName(): ?string // field 5
public function hasArrangement(): bool
public function getPlaylistItem(): PlaylistItem // raw proto access
}
```
- **REFACTOR**: Ensure clean null handling, extract repetitive patterns
**Must NOT do**:
- Do NOT name this class `PlaylistItem` — collision with `Rv\Data\PlaylistItem`
- Do NOT add file I/O — this is a pure data wrapper
- Do NOT parse embedded .pro files here — that's the reader's job
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Medium complexity wrapper with multiple type branches and TDD workflow
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 2 (with Tasks 5, 6)
- **Blocks**: Tasks 5, 6, 7, 8, 9
- **Blocked By**: Task 1 (needs regenerated proto with field 5)
**References**:
**Pattern References**:
- `php/src/Slide.php` — Follow this wrapper pattern: constructor takes proto object, provides typed getters, exposes raw proto via `getX()` method. The Slide class wraps a Cue protobuf the same way PlaylistEntry wraps PlaylistItem
- `php/src/Group.php` — Another wrapper example: simple constructor + getters for UUID, name, plus derived data
- `php/tests/SlideTest.php` — Test pattern: construct proto objects manually in test, wrap them, assert getter results
**API/Type References**:
- `php/generated/Rv/Data/PlaylistItem.php` — The proto class being wrapped. Key methods: `getItemType()` returns string from oneof ('header'|'presentation'|'cue'|'planning_center'|'placeholder'), `getHeader()`, `getPresentation()`, `getPlaceholder()`, `getCue()`
- `php/generated/Rv/Data/PlaylistItem/Presentation.php` — Presentation sub-message: `getDocumentPath()`, `getArrangement()`, `getArrangementName()`, `getUserMusicKey()`
- `php/generated/Rv/Data/PlaylistItem/Header.php` — Header sub-message: `getColor()` returns `Rv\Data\Color`
- `php/generated/Rv/Data/Color.php``getRed()`, `getGreen()`, `getBlue()`, `getAlpha()` all return float
**WHY Each Reference Matters**:
- `Slide.php` + `Group.php`: These ARE the pattern. PlaylistEntry must look and feel like these classes — same constructor style, same getter conventions, same raw-proto-access method
- `PlaylistItem.php`: The protobuf API surface — you need to know the exact method names to delegate to
- `SlideTest.php`: Shows how to construct proto objects in tests without file I/O
**Acceptance Criteria**:
- [x] `php/tests/PlaylistEntryTest.php` exists with ≥10 test methods
- [x] `php/src/PlaylistEntry.php` exists in namespace `ProPresenter\Parser`
- [x] `cd php && php vendor/bin/phpunit --filter PlaylistEntryTest` — ALL tests pass
- [x] Tests cover all 4 item types (header, presentation, placeholder, cue)
- [x] `getArrangementName()` returns field 5 value for presentation items
**QA Scenarios:**
```
Scenario: Create a presentation entry and read arrangement name
Tool: Bash
Preconditions: PlaylistEntry + proto field 5 implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$pres = new Rv\Data\PlaylistItem\Presentation();
\$pres->setArrangementName('test-arrangement');
\$item = new Rv\Data\PlaylistItem();
\$item->setPresentation(\$pres);
\$item->setName('TestSong');
\$entry = new ProPresenter\Parser\PlaylistEntry(\$item);
echo \$entry->getArrangementName();
"
2. Assert output is 'test-arrangement'
Expected Result: 'test-arrangement' printed
Failure Indicators: Empty output, exception, or wrong value
Evidence: .sisyphus/evidence/task-4-arrangement-name.txt
Scenario: Type detection for header item
Tool: Bash
Preconditions: PlaylistEntry implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$header = new Rv\Data\PlaylistItem\Header();
\$item = new Rv\Data\PlaylistItem();
\$item->setHeader(\$header);
\$entry = new ProPresenter\Parser\PlaylistEntry(\$item);
echo \$entry->getType() . ' ' . (\$entry->isHeader() ? 'YES' : 'NO');
"
2. Assert output is 'header YES'
Expected Result: Type correctly identified as 'header'
Failure Indicators: Wrong type string or isHeader() returns false
Evidence: .sisyphus/evidence/task-4-type-detection.txt
```
**Commit**: YES (groups with T5, T6)
- Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
- Files: `php/src/PlaylistEntry.php`, `php/tests/PlaylistEntryTest.php`
- Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistEntryTest`
- [x] 5. PlaylistNode wrapper class (TDD)
**What to do**:
- **RED**: Write `php/tests/PlaylistNodeTest.php` first:
- `getUuid`: returns playlist node UUID
- `getName`: returns playlist name (e.g., "TestPlaylist", "03-01", "2026-02-07")
- `getType`: returns type string from proto enum
- `getEntries`: returns `PlaylistEntry[]` for leaf nodes (nodes with `items`)
- `getChildNodes`: returns `PlaylistNode[]` for container nodes (nodes with `playlists`)
- `isContainer`: true if node has child playlists (PlaylistArray)
- `isLeaf`: true if node has items (PlaylistItems)
- `getEntryCount`: returns number of items
- Test with manually constructed proto Playlist objects
- **GREEN**: Implement `php/src/PlaylistNode.php`:
```php
namespace ProPresenter\Parser;
use Rv\Data\Playlist;
class PlaylistNode {
private array $entries = [];
private array $childNodes = [];
public function __construct(private readonly Playlist $playlist) {
// Parse items into PlaylistEntry objects
// Parse child playlists into PlaylistNode objects
}
public function getUuid(): string
public function getName(): string
public function getType(): int
public function getEntries(): array // PlaylistEntry[]
public function getChildNodes(): array // PlaylistNode[]
public function isContainer(): bool
public function isLeaf(): bool
public function getEntryCount(): int
public function getPlaylist(): Playlist // raw proto access
}
```
- **REFACTOR**: Clean up, ensure consistent null safety
**Must NOT do**:
- Do NOT name this class `Playlist` — collision with `Rv\Data\Playlist`
- Do NOT add any file I/O
- Do NOT try to resolve document paths to actual files
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Medium complexity wrapper with recursive tree structure
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 2 (with Tasks 4, 6)
- **Blocks**: Tasks 6, 7, 8, 9
- **Blocked By**: Tasks 1, 4 (needs proto + PlaylistEntry)
**References**:
**Pattern References**:
- `php/src/Song.php:21-51` — Constructor pattern: takes proto object, iterates children to build typed wrapper arrays. Song iterates CueGroups to build Group[] and Cues to build Slide[] — PlaylistNode iterates PlaylistItems to build PlaylistEntry[]
- `php/src/Group.php` — Simple wrapper pattern with UUID + name getters
**API/Type References**:
- `php/generated/Rv/Data/Playlist.php` — The proto class being wrapped. Key oneof `ChildrenType`: `getPlaylists()` returns `PlaylistArray` (container), `getItems()` returns `PlaylistItems` (leaf). Use `getChildrenType()` to determine which
- `php/generated/Rv/Data/Playlist/PlaylistArray.php``getPlaylists()` returns repeated Playlist
- `php/generated/Rv/Data/Playlist/PlaylistItems.php``getItems()` returns repeated PlaylistItem
- `php/src/PlaylistEntry.php` — The wrapper created in Task 4, used to wrap each PlaylistItem
**WHY Each Reference Matters**:
- `Song.php:21-51`: The constructor loop pattern — iterating proto repeated fields and wrapping each into domain objects. PlaylistNode does the same thing with PlaylistItems
- `Playlist.php` oneof ChildrenType: CRITICAL — must check `getChildrenType()` to know if node has child playlists or items. Wrong check = empty data
**Acceptance Criteria**:
- [x] `php/tests/PlaylistNodeTest.php` exists with ≥8 test methods
- [x] `php/src/PlaylistNode.php` exists in namespace `ProPresenter\Parser`
- [x] `cd php && php vendor/bin/phpunit --filter PlaylistNodeTest` — ALL tests pass
- [x] Container node returns child PlaylistNode objects
- [x] Leaf node returns PlaylistEntry objects
**QA Scenarios:**
```
Scenario: Leaf node with items returns entries
Tool: Bash
Preconditions: PlaylistNode + PlaylistEntry implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$item = new Rv\Data\PlaylistItem();
\$item->setName('TestItem');
\$items = new Rv\Data\Playlist\PlaylistItems();
\$items->setItems([\$item]);
\$playlist = new Rv\Data\Playlist();
\$playlist->setName('TestPlaylist');
\$playlist->setItems(\$items);
\$node = new ProPresenter\Parser\PlaylistNode(\$playlist);
echo \$node->getName() . ' ' . \$node->getEntryCount() . ' ' . (\$node->isLeaf() ? 'LEAF' : 'CONTAINER');
"
2. Assert output is 'TestPlaylist 1 LEAF'
Expected Result: Node correctly identified as leaf with 1 entry
Failure Indicators: Wrong count or wrong type detection
Evidence: .sisyphus/evidence/task-5-leaf-node.txt
```
**Commit**: YES (groups with T4, T6)
- Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
- Files: `php/src/PlaylistNode.php`, `php/tests/PlaylistNodeTest.php`
- Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistNodeTest`
- [x] 6. PlaylistArchive wrapper class (TDD)
**What to do**:
- **RED**: Write `php/tests/PlaylistArchiveTest.php` first:
- `getName`: returns the child playlist name (e.g., "TestPlaylist")
- `getRootNode`: returns the root PlaylistNode (always named "PLAYLIST")
- `getPlaylistNode`: returns the first child node (the actual named playlist)
- `getEntries`: shortcut to get all entries from the playlist node
- `getEntryCount`: total items count
- `getApplicationInfo`: returns app info string or object
- `getType`: returns document type (TYPE_PRESENTATION)
- `getEmbeddedFiles`: returns array of `['filename' => string, 'data' => string]` for all ZIP entries except `data`
- `getEmbeddedProFiles`: returns only .pro file entries
- `getEmbeddedMediaFiles`: returns only non-.pro, non-data entries
- `getEmbeddedSong(string $filename)`: lazy-parses a .pro file into a Song object via ProFileReader
- Test with manually constructed proto objects (no file I/O in unit tests)
- **GREEN**: Implement `php/src/PlaylistArchive.php`:
```php
namespace ProPresenter\Parser;
use Rv\Data\PlaylistDocument;
class PlaylistArchive {
private PlaylistNode $rootNode;
private array $embeddedFiles = []; // filename => raw bytes
private array $parsedSongs = []; // filename => Song (lazy cache)
public function __construct(
private readonly PlaylistDocument $document,
array $embeddedFiles = [],
) {}
public function getName(): string // child playlist name
public function getRootNode(): PlaylistNode
public function getPlaylistNode(): ?PlaylistNode // first child
public function getEntries(): array // PlaylistEntry[]
public function getEntryCount(): int
public function getType(): int
public function getEmbeddedFiles(): array
public function getEmbeddedProFiles(): array
public function getEmbeddedMediaFiles(): array
public function getEmbeddedSong(string $filename): ?Song // lazy parse
public function getDocument(): PlaylistDocument // raw proto
}
```
- **REFACTOR**: Ensure lazy caching works correctly for Song objects
**Must NOT do**:
- Do NOT auto-parse .pro files in constructor — lazy parsing only in `getEmbeddedSong()`
- Do NOT name this class `PlaylistDocument` — collision with proto
- Do NOT store file paths — only in-memory bytes
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Top-level wrapper integrating nodes, entries, and embedded files
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 2 (with Tasks 4, 5)
- **Blocks**: Tasks 7, 8, 9, 10
- **Blocked By**: Tasks 1, 4, 5 (needs proto + both sub-wrappers)
**References**:
**Pattern References**:
- `php/src/Song.php` — Top-level wrapper pattern: constructor takes proto object, builds internal indexes, provides convenience methods. PlaylistArchive is the Song equivalent for playlists
- `php/src/ProFileReader.php:33-36` — Shows how Song is constructed from proto. PlaylistArchive constructor follows same pattern but also receives embedded files
**API/Type References**:
- `php/generated/Rv/Data/PlaylistDocument.php` — The proto class being wrapped. `getRootNode()` returns `Playlist`, `getType()` returns int, `getApplicationInfo()` returns `ApplicationInfo`
- `php/src/PlaylistNode.php` — Wrapper from Task 5 — used for root node and child nodes
- `php/src/PlaylistEntry.php` — Wrapper from Task 4 — used via PlaylistNode
- `php/src/ProFileReader.php` — Used in `getEmbeddedSong()` to lazily parse .pro bytes. Important: ProFileReader reads from file path, so you need to use the `Presentation` proto directly: `(new Presentation())->mergeFromString($bytes)` then `new Song($presentation)`
**WHY Each Reference Matters**:
- `Song.php`: THIS is the class being mirrored. PlaylistArchive should feel identical in API style
- `PlaylistDocument.php`: The exact proto API surface to delegate to
- `ProFileReader.php:33-36`: Shows the proto->Song construction pattern. For lazy parsing, replicate this inline rather than going through file I/O
**Acceptance Criteria**:
- [x] `php/tests/PlaylistArchiveTest.php` exists with ≥10 test methods
- [x] `php/src/PlaylistArchive.php` exists in namespace `ProPresenter\Parser`
- [x] `cd php && php vendor/bin/phpunit --filter PlaylistArchiveTest` — ALL tests pass
- [x] `getEmbeddedSong()` returns Song object via lazy parsing
- [x] `getEmbeddedProFiles()` and `getEmbeddedMediaFiles()` correctly partition embedded files
**QA Scenarios:**
```
Scenario: Archive with embedded files partitions correctly
Tool: Bash
Preconditions: PlaylistArchive implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$doc = new Rv\Data\PlaylistDocument();
\$root = new Rv\Data\Playlist();
\$root->setName('PLAYLIST');
\$doc->setRootNode(\$root);
\$files = ['Song.pro' => 'prodata', 'Users/path/image.jpg' => 'imgdata'];
\$archive = new ProPresenter\Parser\PlaylistArchive(\$doc, \$files);
echo count(\$archive->getEmbeddedProFiles()) . ' ' . count(\$archive->getEmbeddedMediaFiles());
"
2. Assert output is '1 1'
Expected Result: 1 .pro file and 1 media file correctly partitioned
Failure Indicators: Wrong counts
Evidence: .sisyphus/evidence/task-6-embedded-partition.txt
```
**Commit**: YES (groups with T4, T5)
- Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
- Files: `php/src/PlaylistArchive.php`, `php/tests/PlaylistArchiveTest.php`
- Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistArchiveTest`
- [x] 7. ProPlaylistReader (TDD)
**What to do**:
- **RED**: Write `php/tests/ProPlaylistReaderTest.php` first:
- `readThrowsOnMissingFile`: nonexistent path → InvalidArgumentException
- `readThrowsOnEmptyFile`: 0-byte file → RuntimeException
- `readThrowsOnInvalidZip`: non-ZIP content → RuntimeException
- `readLoadsTestPlaylist`: reads `ref/TestPlaylist.proplaylist`, verifies name, entry count, item types
- `readLoadsGottesdienst`: reads `ref/ExamplePlaylists/Gottesdienst.proplaylist`, verifies it has >20 entries
- `readExtractsEmbeddedProFiles`: verifies `.pro` files are available via `getEmbeddedProFiles()`
- `readExtractsEmbeddedMediaFiles`: verifies media files are available
- `readParsesAllItemTypes`: verifies headers, presentations, placeholders are correctly typed
- `readPreservesArrangementName`: reads a file with arrangement, verifies field 5 value
- `readHandlesAllExamplePlaylists`: loop over all 4 test files, verify each loads without error
- **GREEN**: Implement `php/src/ProPlaylistReader.php`:
```php
namespace ProPresenter\Parser;
use Rv\Data\PlaylistDocument;
final class ProPlaylistReader {
public static function read(string $filePath): PlaylistArchive {
// 1. Validate file exists, not empty
// 2. Read raw bytes
// 3. Fix ZIP64 headers via Zip64Fixer::fix()
// 4. Write fixed bytes to temp file
// 5. Open with ZipArchive
// 6. Extract 'data' entry -> deserialize as PlaylistDocument
// 7. Extract all other entries as embedded files
// 8. Close ZipArchive, delete temp file
// 9. Return new PlaylistArchive($document, $embeddedFiles)
}
}
```
- Handle the edge case where a .proplaylist might already have valid ZIP headers (no fix needed)
- **REFACTOR**: Clean error messages, ensure temp file cleanup in all code paths (try/finally)
**Must NOT do**:
- Do NOT leave temp files behind on error — use try/finally
- Do NOT decompress/recompress content — only read entries
- Do NOT auto-parse .pro files — store as raw bytes (lazy parsing is in PlaylistArchive)
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: Complex I/O with ZIP handling, temp files, error paths, and integration of Zip64Fixer + proto deserialization
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 3 (with Tasks 8, 9, 10)
- **Blocks**: Tasks 8, 10, 11, 12
- **Blocked By**: Tasks 2, 6 (needs Zip64Fixer + PlaylistArchive)
**References**:
**Pattern References**:
- `php/src/ProFileReader.php` — EXACT pattern to follow: static `read(string $filePath)` method, same validation (file exists, not empty), same exception types. The difference is ProPlaylistReader reads a ZIP instead of raw protobuf
- `php/tests/ProFileReaderTest.php` — Test structure: error cases first (missing file, empty file), then happy paths with real files, then diverse file loading
**API/Type References**:
- `php/src/Zip64Fixer.php` — From Task 2: `Zip64Fixer::fix(string $data): string` — call before opening ZIP
- `php/src/PlaylistArchive.php` — From Task 6: constructor takes `(PlaylistDocument, array $embeddedFiles)` — this is what we return
- `php/generated/Rv/Data/PlaylistDocument.php``mergeFromString()` to deserialize the `data` entry
- PHP `ZipArchive``open()`, `getFromName()`, `numFiles`, `getNameIndex()`, `close()`
**WHY Each Reference Matters**:
- `ProFileReader.php`: The TEMPLATE. ProPlaylistReader should be structurally identical, just with ZIP extraction added
- `Zip64Fixer.php`: MUST be called before ZipArchive::open() or it will fail with error 21
- `PlaylistDocument::mergeFromString()`: The `data` entry is raw protobuf bytes — deserialize with this method
**Test Data**:
- `ref/TestPlaylist.proplaylist` — Small: 2 .pro files, 1 image, ~4 items
- `ref/ExamplePlaylists/Gottesdienst.proplaylist` — Large: 14MB, 25+ items
- `ref/ExamplePlaylists/Gottesdienst 2.proplaylist` — 10MB
- `ref/ExamplePlaylists/Gottesdienst 3.proplaylist` — 16MB
**Acceptance Criteria**:
- [x] `php/tests/ProPlaylistReaderTest.php` exists with ≥10 test methods
- [x] `php/src/ProPlaylistReader.php` exists with static `read()` method
- [x] `cd php && php vendor/bin/phpunit --filter ProPlaylistReaderTest` — ALL tests pass
- [x] All 4 .proplaylist test files load successfully
- [x] Embedded .pro files and media files are accessible from returned PlaylistArchive
- [x] No temp files left behind after read (success or failure)
**QA Scenarios:**
```
Scenario: Read TestPlaylist and inspect entries
Tool: Bash
Preconditions: ProPlaylistReader + all dependencies implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$archive = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
echo 'Name: ' . \$archive->getName() . PHP_EOL;
echo 'Entries: ' . \$archive->getEntryCount() . PHP_EOL;
echo 'ProFiles: ' . count(\$archive->getEmbeddedProFiles()) . PHP_EOL;
foreach (\$archive->getEntries() as \$e) { echo \$e->getType() . ': ' . \$e->getName() . PHP_EOL; }"
2. Assert output contains 'Name: TestPlaylist'
3. Assert output contains at least 2 entries
Expected Result: Playlist loaded with correct name and typed entries
Failure Indicators: Exception, empty entries, wrong name
Evidence: .sisyphus/evidence/task-7-read-test-playlist.txt
Scenario: Error on nonexistent file
Tool: Bash
Preconditions: ProPlaylistReader implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
try { ProPresenter\Parser\ProPlaylistReader::read('/nonexistent.proplaylist'); echo 'NO_EXCEPTION'; }
catch (InvalidArgumentException \$e) { echo 'OK'; }"
2. Assert output is 'OK'
Expected Result: InvalidArgumentException thrown
Failure Indicators: 'NO_EXCEPTION' or different exception type
Evidence: .sisyphus/evidence/task-7-error-nonexistent.txt
```
**Commit**: YES
- Message: `feat(playlist): add ProPlaylistReader`
- Files: `php/src/ProPlaylistReader.php`, `php/tests/ProPlaylistReaderTest.php`
- Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistReaderTest`
- [x] 8. ProPlaylistWriter (TDD)
**What to do**:
- **RED**: Write `php/tests/ProPlaylistWriterTest.php` first:
- `writeThrowsOnMissingDirectory`: target dir doesn't exist → InvalidArgumentException
- `writeCreatesValidZipFile`: write a PlaylistArchive, verify output is valid ZIP
- `writeContainsDataEntry`: output ZIP has `data` entry with valid protobuf
- `writeContainsEmbeddedProFiles`: .pro files from archive are in ZIP at root level
- `writeContainsEmbeddedMediaFiles`: media files are in ZIP at their original paths
- `writeDeduplicatesFiles`: same filename referenced twice → stored once
- `writeDataEntryDeserializesToSameDocument`: read back `data` entry, compare with original
- **GREEN**: Implement `php/src/ProPlaylistWriter.php`:
```php
namespace ProPresenter\Parser;
final class ProPlaylistWriter {
public static function write(PlaylistArchive $archive, string $filePath): void {
// 1. Validate target directory exists
// 2. Create temp ZipArchive
// 3. Serialize PlaylistDocument to protobuf -> add as 'data' entry
// 4. Add each embedded file (pro files at root, media at their paths)
// 5. Close ZipArchive
// 6. Move/copy temp file to target path
}
}
```
- Use `ZipArchive::CM_STORE` for no compression (matching ProPresenter behavior)
- Write clean, standard-compliant ZIP — do NOT reproduce ProPresenter's 98-byte quirk
- **REFACTOR**: Ensure temp file cleanup, consistent error messages
**Must NOT do**:
- Do NOT reproduce ProPresenter's broken ZIP64 headers
- Do NOT compress entries — use store method
- Do NOT modify the PlaylistArchive or its Document during write
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: ZIP creation with specific constraints, temp file management, and careful protobuf serialization
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 3 (with Tasks 7, 9, 10)
- **Blocks**: Tasks 11, 12
- **Blocked By**: Tasks 6, 7 (needs PlaylistArchive + Reader for verification)
**References**:
**Pattern References**:
- `php/src/ProFileWriter.php` — EXACT pattern to follow: static `write(Song, string)` method, same validation, same exception types. The difference is writing ZIP instead of raw protobuf
- `php/tests/ProFileWriterTest.php` — Test pattern for writer tests
**API/Type References**:
- `php/src/PlaylistArchive.php``getDocument()` returns `PlaylistDocument` for serialization, `getEmbeddedFiles()` returns `['filename' => bytes]` map
- PHP `ZipArchive``open()` with `ZipArchive::CREATE`, `addFromString($name, $data)`, `setCompressionName($name, ZipArchive::CM_STORE)`, `close()`
- `PlaylistDocument::serializeToString()` — Serializes proto to bytes for the `data` entry
**WHY Each Reference Matters**:
- `ProFileWriter.php`: Structural template — same validation pattern, same method signature style
- `PlaylistArchive`: Source of ALL data to write — document for `data` entry, embedded files for other entries
**Acceptance Criteria**:
- [x] `php/tests/ProPlaylistWriterTest.php` exists with ≥7 test methods
- [x] `php/src/ProPlaylistWriter.php` exists with static `write()` method
- [x] `cd php && php vendor/bin/phpunit --filter ProPlaylistWriterTest` — ALL tests pass
- [x] Written ZIP opens with standard `unzip -l` without errors
- [x] Written ZIP uses store compression (no deflate)
**QA Scenarios:**
```
Scenario: Write and verify ZIP structure
Tool: Bash
Preconditions: Reader + Writer both implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$archive = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
ProPresenter\Parser\ProPlaylistWriter::write(\$archive, '/tmp/test-write.proplaylist');
echo file_exists('/tmp/test-write.proplaylist') ? 'EXISTS' : 'MISSING';"
2. unzip -l /tmp/test-write.proplaylist 2>&1
3. Assert ZIP listing shows 'data' entry and .pro files
4. rm /tmp/test-write.proplaylist
Expected Result: Valid ZIP with data + embedded files
Failure Indicators: unzip errors or missing entries
Evidence: .sisyphus/evidence/task-8-write-verify.txt
```
**Commit**: YES
- Message: `feat(playlist): add ProPlaylistWriter`
- Files: `php/src/ProPlaylistWriter.php`, `php/tests/ProPlaylistWriterTest.php`
- Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistWriterTest`
- [x] 9. ProPlaylistGenerator (TDD)
**What to do**:
- **RED**: Write `php/tests/ProPlaylistGeneratorTest.php` first:
- `generateCreatesPlaylistArchive`: returns a PlaylistArchive object
- `generateSetsPlaylistName`: verify archive name matches input
- `generateCreatesHeaders`: header items with name and color
- `generateCreatesPresentationItems`: presentation items with document path, arrangement UUID, arrangement name
- `generateCreatesPlaceholders`: placeholder items
- `generateSetsApplicationInfo`: application_info with platform and version
- `generateSetsTypePresentation`: document type = TYPE_PRESENTATION
- `generateAndWriteCreatesFile`: writes to disk and verifies readable
- **GREEN**: Implement `php/src/ProPlaylistGenerator.php`:
```php
namespace ProPresenter\Parser;
final class ProPlaylistGenerator {
public static function generate(
string $name,
array $items, // [{type: 'header'|'presentation'|'placeholder', ...}]
array $embeddedFiles = [], // [filename => bytes]
): PlaylistArchive
public static function generateAndWrite(
string $filePath,
string $name,
array $items,
array $embeddedFiles = [],
): PlaylistArchive
}
```
- Item array format:
- Header: `['type' => 'header', 'name' => 'Section Name', 'color' => [r, g, b, a]]`
- Presentation: `['type' => 'presentation', 'name' => 'Song Title', 'path' => 'file:///...', 'arrangement_uuid' => '...', 'arrangement_name' => 'normal']`
- Placeholder: `['type' => 'placeholder', 'name' => 'Placeholder1']`
- Build proper protobuf structure: PlaylistDocument → root Playlist ("PLAYLIST") → child Playlist (name) → PlaylistItems → PlaylistItem[]
- Set application_info matching ProPresenter defaults (reuse `buildApplicationInfo()` pattern from `ProFileGenerator`)
- Generate UUIDs for all objects
- **REFACTOR**: Extract shared UUID/color/appinfo builders or reuse from ProFileGenerator
**Must NOT do**:
- Do NOT auto-embed .pro file content — user provides embedded files explicitly
- Do NOT validate document_path URLs — accept whatever is provided
- Do NOT add PlanningCenter or audio playlist support
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: Complex protobuf construction with nested structure matching exact ProPresenter format
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 3 (with Tasks 7, 8, 10)
- **Blocks**: Task 11
- **Blocked By**: Task 6 (needs PlaylistArchive)
**References**:
**Pattern References**:
- `php/src/ProFileGenerator.php` — THE template. Follow the exact same patterns: static `generate()` + `generateAndWrite()`, private `buildApplicationInfo()`, `newUuid()`, `uuidFromString()`, `colorFromArray()`. Consider reusing these methods or extracting to a trait/shared utility
- `php/src/ProFileGenerator.php:55-60` — Method signature pattern: `generate(string $name, array $items, ...): Song`. PlaylistGenerator follows this but returns PlaylistArchive
- `php/src/ProFileGenerator.php:137-149``buildApplicationInfo()` method — reuse or duplicate for playlist
**API/Type References**:
- `php/generated/Rv/Data/PlaylistDocument.php` — Top-level container: `setApplicationInfo()`, `setType()`, `setRootNode()`
- `php/generated/Rv/Data/PlaylistDocument/Type.php``TYPE_PRESENTATION = 1`
- `php/generated/Rv/Data/Playlist.php` — Node: `setUuid()`, `setName()`, `setPlaylists()`, `setItems()`
- `php/generated/Rv/Data/Playlist/PlaylistArray.php``setPlaylists()` takes array of Playlist
- `php/generated/Rv/Data/Playlist/PlaylistItems.php``setItems()` takes array of PlaylistItem
- `php/generated/Rv/Data/PlaylistItem.php``setUuid()`, `setName()`, `setHeader()` / `setPresentation()` / `setPlaceholder()`
- `php/generated/Rv/Data/PlaylistItem/Header.php``setColor()`
- `php/generated/Rv/Data/PlaylistItem/Presentation.php``setDocumentPath()`, `setArrangement()`, `setArrangementName()`, `setUserMusicKey()`
- `php/generated/Rv/Data/MusicKeyScale.php` — Default music key: `setMusicKey(MusicKeyScale\MusicKey::MUSIC_KEY_C)`
**WHY Each Reference Matters**:
- `ProFileGenerator.php`: The EXACT pattern to replicate. Same static factory, same UUID generation, same color building, same application info
- All the proto class references: These are the API surface for constructing the nested protobuf tree. Getting any setter name wrong breaks generation
**Acceptance Criteria**:
- [x] `php/tests/ProPlaylistGeneratorTest.php` exists with ≥8 test methods
- [x] `php/src/ProPlaylistGenerator.php` exists with `generate()` and `generateAndWrite()` methods
- [x] `cd php && php vendor/bin/phpunit --filter ProPlaylistGeneratorTest` — ALL tests pass
- [x] Generated playlist has correct structure: PlaylistDocument → root → child → items
- [x] Header, presentation, and placeholder items all generate correctly
**QA Scenarios:**
```
Scenario: Generate a playlist with mixed items
Tool: Bash
Preconditions: ProPlaylistGenerator implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$archive = ProPresenter\Parser\ProPlaylistGenerator::generate('TestService', [
['type' => 'header', 'name' => 'Welcome', 'color' => [0.0, 0.5, 0.8, 1.0]],
['type' => 'presentation', 'name' => 'Amazing Grace', 'path' => 'file:///song.pro'],
['type' => 'placeholder', 'name' => 'Slot1'],
]);
echo 'Name: ' . \$archive->getName() . PHP_EOL;
echo 'Entries: ' . \$archive->getEntryCount() . PHP_EOL;
foreach (\$archive->getEntries() as \$e) { echo \$e->getType() . ': ' . \$e->getName() . PHP_EOL; }"
2. Assert output contains 'Name: TestService'
3. Assert 3 entries with types header, presentation, placeholder
Expected Result: All 3 item types created correctly
Failure Indicators: Wrong count, wrong types, or exception
Evidence: .sisyphus/evidence/task-9-generate-mixed.txt
```
**Commit**: YES
- Message: `feat(playlist): add ProPlaylistGenerator`
- Files: `php/src/ProPlaylistGenerator.php`, `php/tests/ProPlaylistGeneratorTest.php`
- Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistGeneratorTest`
- [x] 10. CLI tool: parse-playlist.php
**What to do**:
- Create `php/bin/parse-playlist.php` following the EXACT structure of `php/bin/parse-song.php`:
- Shebang line: `#!/usr/bin/env php`
- Require autoloader
- Check argc, print usage
- Read file with ProPlaylistReader::read()
- Display:
- Playlist name and UUID
- Application info (platform, version)
- Document type
- Embedded file summary: N .pro files, N media files
- All entries in order, with type-specific details:
- Header: `[H] Section Name` with color
- Presentation: `[P] Song Title (arrangement: normal)` with document path
- Placeholder: `[-] Placeholder Name`
- Cue: `[C] Cue Name`
- Embedded .pro file list
- Embedded media file list
- Error handling: try/catch with user-friendly error messages
**Must NOT do**:
- Do NOT use colors/ANSI codes — keep plain text like parse-song.php
- Do NOT auto-parse embedded .pro files — just list filenames
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Straightforward CLI script following an exact template
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 3 (with Tasks 7, 8, 9)
- **Blocks**: Tasks 12, 13
- **Blocked By**: Task 7 (needs ProPlaylistReader)
**References**:
**Pattern References**:
- `php/bin/parse-song.php` — EXACT template. Copy structure line by line: shebang, autoload, argc check, try/catch, output formatting. Replace Song-specific output with Playlist-specific output
**API/Type References**:
- `php/src/ProPlaylistReader.php``ProPlaylistReader::read($filePath)` returns `PlaylistArchive`
- `php/src/PlaylistArchive.php``getName()`, `getEntries()`, `getEmbeddedProFiles()`, `getEmbeddedMediaFiles()`
- `php/src/PlaylistEntry.php``getType()`, `getName()`, `getArrangementName()`, `getDocumentPath()`, `getHeaderColor()`
**WHY Each Reference Matters**:
- `parse-song.php`: Byte-for-byte template. The playlist CLI should be stylistically identical
**Acceptance Criteria**:
- [x] `php/bin/parse-playlist.php` exists with shebang line
- [x] `php php/bin/parse-playlist.php` (no args) → usage message + exit code 1
- [x] `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` → structured output
- [x] `php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist` → output without errors
- [x] Output shows entries with type indicators ([H], [P], [-], [C])
**QA Scenarios:**
```
Scenario: CLI with TestPlaylist
Tool: Bash
Preconditions: parse-playlist.php and all dependencies implemented
Steps:
1. php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist
2. Assert exit code 0
3. Assert output contains 'Playlist:' or playlist name
4. Assert output contains at least one '[P]' or '[H]' entry
Expected Result: Clean structured output with entry type indicators
Failure Indicators: Non-zero exit, exception trace in output
Evidence: .sisyphus/evidence/task-10-cli-test-playlist.txt
Scenario: CLI with no arguments shows usage
Tool: Bash
Preconditions: parse-playlist.php exists
Steps:
1. php php/bin/parse-playlist.php 2>&1; echo "EXIT:$?"
2. Assert output contains 'Usage:'
3. Assert exit code is 1
Expected Result: Usage message and exit code 1
Failure Indicators: No usage message or exit code 0
Evidence: .sisyphus/evidence/task-10-cli-usage.txt
```
**Commit**: YES
- Message: `feat(cli): add parse-playlist.php CLI tool`
- Files: `php/bin/parse-playlist.php`
- Pre-commit: `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist`
- [x] 11. Round-trip integration tests
**What to do**:
- Add integration tests to `php/tests/ProPlaylistWriterTest.php` (or a new `php/tests/ProPlaylistIntegrationTest.php`):
- `roundTripPreservesPlaylistName`: read → write → read, compare names
- `roundTripPreservesEntryCount`: same number of entries after round-trip
- `roundTripPreservesEntryTypes`: all item types preserved (header/presentation/placeholder)
- `roundTripPreservesArrangementNames`: field 5 values survive round-trip
- `roundTripPreservesEmbeddedFileCount`: same number of embedded files
- `roundTripPreservesDocumentPaths`: presentation document_path URLs unchanged
- `roundTripPreservesHeaderColors`: header color RGBA values unchanged
- `generatedPlaylistReadableByReader`: generate → write → read back
- Use `ref/TestPlaylist.proplaylist` as primary test file (small, fast)
- Write to temp file, read back, compare all fields, clean up temp file
**Must NOT do**:
- Do NOT compare raw bytes (proto serialization may reorder fields) — compare logical values
- Do NOT modify the original test files
**Recommended Agent Profile**:
- **Category**: `deep`
- Reason: Integration testing requiring coordination of reader + writer + generator with thorough field-level comparison
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 4 (with Tasks 12, 13)
- **Blocks**: Nothing
- **Blocked By**: Tasks 7, 8, 9 (needs Reader + Writer + Generator)
**References**:
**Pattern References**:
- `php/tests/BinaryFidelityTest.php` — If exists, shows existing round-trip or fidelity test patterns
- `php/tests/ProFileWriterTest.php` — Shows how writer tests are structured
**API/Type References**:
- `php/src/ProPlaylistReader.php``read(string): PlaylistArchive`
- `php/src/ProPlaylistWriter.php``write(PlaylistArchive, string): void`
- `php/src/ProPlaylistGenerator.php``generate(string, array): PlaylistArchive`
- `php/src/PlaylistArchive.php` — All getter methods for comparison
**WHY Each Reference Matters**:
- Reader + Writer + Generator: All three are exercised together. The integration tests prove they interoperate correctly
- BinaryFidelityTest: If it exists, it shows the project's existing approach to verifying round-trip integrity
**Acceptance Criteria**:
- [x] Integration test file exists with ≥8 test methods
- [x] `cd php && php vendor/bin/phpunit --filter Integration` (or --filter ProPlaylistWriter) — ALL pass
- [x] Round-trip of TestPlaylist.proplaylist preserves all fields
- [x] Generated → written → read back works correctly
**QA Scenarios:**
```
Scenario: Full round-trip of TestPlaylist
Tool: Bash
Preconditions: Reader + Writer implemented
Steps:
1. php -r "
require 'php/vendor/autoload.php';
\$a = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
ProPresenter\Parser\ProPlaylistWriter::write(\$a, '/tmp/rt-test.proplaylist');
\$b = ProPresenter\Parser\ProPlaylistReader::read('/tmp/rt-test.proplaylist');
echo (\$a->getName() === \$b->getName() ? 'NAME_OK' : 'NAME_FAIL') . ' ';
echo (\$a->getEntryCount() === \$b->getEntryCount() ? 'COUNT_OK' : 'COUNT_FAIL');
unlink('/tmp/rt-test.proplaylist');"
2. Assert output is 'NAME_OK COUNT_OK'
Expected Result: Name and entry count preserved through round-trip
Failure Indicators: Any _FAIL in output
Evidence: .sisyphus/evidence/task-11-roundtrip.txt
```
**Commit**: YES (groups with T12)
- Message: `test(playlist): add integration and real-world validation tests`
- Files: `php/tests/ProPlaylistIntegrationTest.php`
- Pre-commit: `cd php && php vendor/bin/phpunit`
- [x] 12. Large-file / real-world validation tests
**What to do**:
- Add validation tests (in `php/tests/ProPlaylistIntegrationTest.php` or separate file):
- `readsAllExamplePlaylistsWithoutError`: loop over all 4 .proplaylist files, read each, assert no exceptions
- `gottesdienstHasExpectedStructure`: the Gottesdienst playlists should have >20 entries, mix of headers and presentations
- `allPresentationItemsHaveDocumentPath`: every presentation entry has a non-empty document path
- `embeddedProFilesExistForPresentations`: for each presentation entry, verify corresponding .pro is in embedded files
- `cliOutputMatchesReaderData`: run parse-playlist.php, verify output entry count matches reader entry count
- Test all files in `ref/ExamplePlaylists/` plus `ref/TestPlaylist.proplaylist`
**Must NOT do**:
- Do NOT modify the test data files
- Do NOT hardcode exact entry counts (might vary) — use minimum thresholds
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: Validation tests against real-world data files
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 4 (with Tasks 11, 13)
- **Blocks**: Nothing
- **Blocked By**: Tasks 7, 10 (needs Reader + CLI)
**References**:
**Pattern References**:
- `php/tests/MassValidationTest.php` — If exists, shows pattern for testing against many files
- `php/tests/ProFileReaderTest.php:52-78``readLoadsDiverseReferenceFilesSuccessfully` — this exact test pattern: loop over real files, verify each loads
**API/Type References**:
- `php/src/ProPlaylistReader.php``read()` to load each test file
- `php/src/PlaylistArchive.php` — Getters for validation assertions
**Test Data**:
- `ref/TestPlaylist.proplaylist`
- `ref/ExamplePlaylists/Gottesdienst.proplaylist`
- `ref/ExamplePlaylists/Gottesdienst 2.proplaylist`
- `ref/ExamplePlaylists/Gottesdienst 3.proplaylist`
**Acceptance Criteria**:
- [x] Validation tests exist with ≥5 test methods
- [x] `cd php && php vendor/bin/phpunit` — ALL tests pass including validation
- [x] All 4 test files load and validate without errors
**QA Scenarios:**
```
Scenario: All example playlists load
Tool: Bash
Preconditions: Reader implemented
Steps:
1. for f in ref/TestPlaylist.proplaylist ref/ExamplePlaylists/*.proplaylist; do
php -r "require 'php/vendor/autoload.php'; \$a = ProPresenter\Parser\ProPlaylistReader::read('$f'); echo basename('$f') . ': ' . \$a->getEntryCount() . ' entries' . PHP_EOL;"
done
2. Assert all 4 files produce output with entry counts > 0
Expected Result: All files load with non-zero entry counts
Failure Indicators: Any exception or 0 entries
Evidence: .sisyphus/evidence/task-12-all-files.txt
```
**Commit**: YES (groups with T11)
- Message: `test(playlist): add integration and real-world validation tests`
- Files: `php/tests/ProPlaylist*Test.php`
- Pre-commit: `cd php && php vendor/bin/phpunit`
- [x] 13. AGENTS.md documentation update
**What to do**:
- Update `AGENTS.md` to document the new .proplaylist module, following the exact style of the existing `.pro` module documentation:
- Add a new section for `.proplaylist` files
- Document the PHP module usage:
- Reading: `ProPlaylistReader::read('path/to/file.proplaylist')``PlaylistArchive`
- Accessing structure: archive → entries → type-specific data
- Writing: `ProPlaylistWriter::write($archive, 'output.proplaylist')`
- Generating: `ProPlaylistGenerator::generate(name, items, embeddedFiles)`
- Lazy Song parsing: `$archive->getEmbeddedSong('filename.pro')`
- Document CLI tool: `php php/bin/parse-playlist.php path/to/file.proplaylist`
- Link to format specification: `spec/pp_playlist_spec.md`
- List key files (all new PHP source files)
**Must NOT do**:
- Do NOT modify existing .pro module documentation
- Do NOT remove or change any existing content in AGENTS.md
- Do NOT add excessive detail — match the conciseness of existing sections
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: Documentation addition following an exact template
- **Skills**: []
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 4 (with Tasks 11, 12)
- **Blocks**: Nothing
- **Blocked By**: Tasks 7, 10 (needs Reader + CLI to be final)
**References**:
**Pattern References**:
- `AGENTS.md` — The EXISTING documentation structure. The playlist section must be added in the same style, same heading levels, same code block formatting as the existing "PHP Module Usage" section
**Acceptance Criteria**:
- [x] `AGENTS.md` contains a new section for `.proplaylist` files
- [x] Section includes reading, writing, generating, CLI usage
- [x] Section lists all new key files
- [x] Existing content unchanged
**QA Scenarios:**
```
Scenario: AGENTS.md has playlist documentation
Tool: Bash (grep)
Preconditions: Task 13 complete
Steps:
1. grep -c 'proplaylist\|ProPlaylistReader\|PlaylistArchive\|parse-playlist' AGENTS.md
2. Assert count >= 4
Expected Result: All key terms present in documentation
Failure Indicators: Key terms missing
Evidence: .sisyphus/evidence/task-13-agents-md.txt
```
**Commit**: YES
- Message: `docs(agents): update AGENTS.md with playlist module documentation`
- Files: `AGENTS.md`
- Pre-commit: —
## Final Verification Wave
> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
- [x] F1. **Plan Compliance Audit**`oracle`
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`
- [x] F2. **Code Quality Review**`unspecified-high`
Run `php vendor/bin/phpunit` from `php/` directory. Review all new files for: hardcoded paths, empty catches, `@` error suppression, unused imports, inconsistent naming. Check AI slop: excessive comments, over-abstraction, generic variable names. Verify PSR-4 autoloading works for all new classes.
Output: `Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT`
- [x] F3. **Real Manual QA**`unspecified-high`
Start from clean state. Run `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist`. Run with all 3 Gottesdienst playlists. Verify output is human-readable and complete. Test error cases: nonexistent file, non-ZIP file, empty file. Run round-trip test: read → write → read and compare.
Output: `CLI [N/N pass] | Error handling [N/N] | Round-trip [PASS/FAIL] | VERDICT`
- [x] F4. **Scope Fidelity Check**`deep`
For each task: read "What to do", read actual diff. Verify 1:1 — everything in spec was built, nothing beyond spec was built. Check "Must NOT do" compliance. Detect cross-task contamination. Flag unaccounted changes.
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`
---
## Commit Strategy
| Group | Message | Files | Pre-commit |
|-------|---------|-------|------------|
| T1 | `feat(proto): add arrangement_name field 5 to PlaylistItem.Presentation` | `php/proto/playlist.proto`, `php/generated/**` | — |
| T2 | `feat(playlist): add Zip64Fixer for ProPresenter ZIP archives` | `php/src/Zip64Fixer.php`, `php/tests/Zip64FixerTest.php` | `php vendor/bin/phpunit --filter Zip64FixerTest` |
| T3 | `docs(spec): add .proplaylist format specification` | `spec/pp_playlist_spec.md` | — |
| T4+T5+T6 | `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers` | `php/src/Playlist*.php`, `php/tests/Playlist*Test.php` | `php vendor/bin/phpunit --filter 'PlaylistEntry\|PlaylistNode\|PlaylistArchive'` |
| T7 | `feat(playlist): add ProPlaylistReader` | `php/src/ProPlaylistReader.php`, `php/tests/ProPlaylistReaderTest.php` | `php vendor/bin/phpunit --filter ProPlaylistReaderTest` |
| T8 | `feat(playlist): add ProPlaylistWriter` | `php/src/ProPlaylistWriter.php`, `php/tests/ProPlaylistWriterTest.php` | `php vendor/bin/phpunit --filter ProPlaylistWriterTest` |
| T9 | `feat(playlist): add ProPlaylistGenerator` | `php/src/ProPlaylistGenerator.php`, `php/tests/ProPlaylistGeneratorTest.php` | `php vendor/bin/phpunit --filter ProPlaylistGeneratorTest` |
| T10 | `feat(cli): add parse-playlist.php CLI tool` | `php/bin/parse-playlist.php` | `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` |
| T11+T12 | `test(playlist): add integration and real-world validation tests` | `php/tests/ProPlaylist*Test.php` | `php vendor/bin/phpunit` |
| T13 | `docs(agents): update AGENTS.md with playlist module documentation` | `AGENTS.md` | — |
---
## Success Criteria
### Verification Commands
```bash
# All tests pass
cd php && php vendor/bin/phpunit # Expected: OK (N tests, N assertions)
# CLI tool works with test file
php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist # Expected: structured output showing items
# CLI tool works with large real-world file
php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist # Expected: structured output, no errors
# Reader handles all example files
for f in ref/ExamplePlaylists/*.proplaylist; do php php/bin/parse-playlist.php "$f"; done # Expected: all succeed
```
### Final Checklist
- [x] All "Must Have" present
- [x] All "Must NOT Have" absent
- [x] All PHPUnit tests pass
- [x] CLI tool handles all 4 test files
- [x] Round-trip fidelity verified
- [x] Format spec document complete
- [x] AGENTS.md updated