# 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