- Record final project status in learnings.md - Add all task evidence files (43 files) - Add work plan with all 29 checkboxes complete - Add boulder state tracking Project complete: 99 tests passing, all deliverables verified
74 KiB
ProPresenter .proplaylist PHP Module
TL;DR
Quick Summary: Build a complete PHP module for reading, writing, and generating ProPresenter 7
.proplaylistfiles. The format is a ZIP64 archive containing protobuf-encoded playlist metadata (datafile), embedded.prosong files, and media files. Extends the existingphp/src/parser codebase following the same static-factory + protobuf-wrapper patterns.Deliverables:
- Proto modification: add undocumented
arrangement_namefield 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;toPlaylistItem.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
storecompression;datafile contains protobufPlaylistDocument - 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.Presentationstores 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(notPlaylist/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.protowith field 5 added - Regenerated PHP classes in
php/generated/ php/src/Zip64Fixer.php— ZIP64 header patching utilityphp/src/PlaylistArchive.php— top-level playlist wrapper (likeSong.php)php/src/PlaylistNode.php— playlist/folder node wrapper (likeGroup.php)php/src/PlaylistEntry.php— playlist item wrapper (header/presentation/placeholder/cue)php/src/ProPlaylistReader.php— reads .proplaylist files (likeProFileReader.php)php/src/ProPlaylistWriter.php— writes .proplaylist files (likeProFileWriter.php)php/src/ProPlaylistGenerator.php— generates playlists from scratch (likeProFileGenerator.php)php/bin/parse-playlist.php— CLI tool (likephp/bin/parse-song.php)spec/pp_playlist_spec.md— format specification (likespec/pp_song_spec.md)php/tests/Zip64FixerTest.php— tests for ZIP patchingphp/tests/PlaylistArchiveTest.php— tests for wrapper classphp/tests/PlaylistNodeTest.php— tests for node wrapperphp/tests/PlaylistEntryTest.php— tests for entry wrapperphp/tests/ProPlaylistReaderTest.php— tests for readerphp/tests/ProPlaylistWriterTest.php— tests for writerphp/tests/ProPlaylistGeneratorTest.php— tests for generator
Definition of Done
php vendor/bin/phpunitfromphp/directory — ALL tests pass (0 failures, 0 errors)php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist— outputs structured playlist dataphp php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist— handles large real-world file- Round-trip: read → write → read produces identical data
- 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
.protosource and regenerate - Do NOT name wrapper classes
PlaylistorPlaylistItem— collision withRv\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 ClassNameor 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.
-
1. Proto modification: Add arrangement_name field 5 + regenerate PHP classes
What to do:
- Edit
php/proto/playlist.proto: in thePlaylistItem.Presentationmessage (line ~89-94), addstring arrangement_name = 5;after theuser_music_keyfield - Regenerate PHP protobuf classes by running
protocwith the same flags used for the existing generation. Check if there's a Makefile or shell script inphp/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.phpnow hasgetArrangementName()andsetArrangementName()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.protosource - 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— ThePlaylistItem.Presentationmessage 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. ThePlaylistItem.Presentationmessage 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:
php/proto/playlist.protocontainsstring arrangement_name = 5;insidePlaylistItem.Presentationmessagephp/generated/Rv/Data/PlaylistItem/Presentation.phpcontains methodsgetArrangementName()andsetArrangementName()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.txtCommit: 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
- Edit
-
2. Zip64Fixer utility (TDD)
What to do:
- RED: Write
php/tests/Zip64FixerTest.phpfirst with these test cases:fixReturnsValidZipForProPresenterExport: Passref/TestPlaylist.proplaylistbytes → output should open withZipArchive::open()without errorsfixReturnsValidZipForLargePlaylist: Passref/ExamplePlaylists/Gottesdienst.proplaylist→ samefixThrowsOnNonZipData: Pass random bytes → expect RuntimeExceptionfixThrowsOnTooSmallData: Pass 10 bytes → expect RuntimeExceptionfixPreservesAllEntries: After fix, ZipArchive lists same entries asunzip -lfixIdempotent: Fixing an already-fixed file produces identical output
- GREEN: Implement
php/src/Zip64Fixer.phpwith a single static method:final class Zip64Fixer { public static function fix(string $zipData): string } - Algorithm:
- Find EOCD signature (
\x50\x4b\x05\x06) scanning from end of file - Read CD offset from EOCD (offset +16, 4 bytes, little-endian)
- If CD offset == 0xFFFFFFFF, find ZIP64 EOCD locator (signature
\x50\x4b\x06\x07), then ZIP64 EOCD (signature\x50\x4b\x06\x06) - From ZIP64 EOCD: read CD offset (offset +48, 8 bytes LE) and zip64_eocd_position
- Calculate
correct_cd_size = zip64_eocd_position - cd_offset - Patch ZIP64 EOCD field at offset +40 (8 bytes LE) with correct_cd_size
- Patch regular EOCD field at offset +12 (4 bytes LE) with
min(correct_cd_size, 0xFFFFFFFF) - Return patched bytes
- Find EOCD signature (
- 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 sameInvalidArgumentException/RuntimeExceptionpatternphp/tests/ProFileReaderTest.php— Test structure with#[Test]attributes,dirname(__DIR__, 2)for test data paths
API/Type References:
- PHP
ZipArchiveclass — Used to verify the fix works.ZipArchive::open()returnstrueon 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 signature0x06064b50. ZIP64 EOCD locator0x07064b50 - The 98-byte discrepancy: ProPresenter writes CD size field as
actual_cd_size + 98consistently across all exported files
WHY Each Reference Matters:
ProFileReader.php: Shows the exact error handling pattern (exception types, message formatting) to replicateProFileReaderTest.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 testref/ExamplePlaylists/Gottesdienst.proplaylist— 14MB real-world file
Acceptance Criteria:
php/tests/Zip64FixerTest.phpexists with ≥6 test methodsphp/src/Zip64Fixer.phpexists withfix(string $zipData): stringstatic methodcd php && php vendor/bin/phpunit --filter Zip64FixerTest— ALL tests pass- 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.txtCommit: 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
- RED: Write
-
3. Format specification document
What to do:
- Create
spec/pp_playlist_spec.mddocumenting 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:
datafile at root,.profiles at root, media files at original paths - Protobuf structure:
PlaylistDocument→Playlist(root_node) →PlaylistArray→ childPlaylist→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:
spec/pp_playlist_spec.mdexists- Document covers: container format, ZIP layout, protobuf structure, all item types, URL conventions
- Document includes concrete examples
- Follows
spec/pp_song_spec.mdstyle
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.txtCommit: YES
- Message:
docs(spec): add .proplaylist format specification - Files:
spec/pp_playlist_spec.md - Pre-commit: —
- Create
-
4. PlaylistEntry wrapper class (TDD)
What to do:
- RED: Write
php/tests/PlaylistEntryTest.phpfirst:getUuid: returns item UUID stringgetName: returns item name stringgetType: 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.profrom full URL) - Test with manually constructed protobuf objects (no file I/O needed)
- GREEN: Implement
php/src/PlaylistEntry.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 withRv\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 viagetX()method. The Slide class wraps a Cue protobuf the same way PlaylistEntry wraps PlaylistItemphp/src/Group.php— Another wrapper example: simple constructor + getters for UUID, name, plus derived dataphp/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()returnsRv\Data\Colorphp/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 methodPlaylistItem.php: The protobuf API surface — you need to know the exact method names to delegate toSlideTest.php: Shows how to construct proto objects in tests without file I/O
Acceptance Criteria:
php/tests/PlaylistEntryTest.phpexists with ≥10 test methodsphp/src/PlaylistEntry.phpexists in namespaceProPresenter\Parsercd php && php vendor/bin/phpunit --filter PlaylistEntryTest— ALL tests pass- Tests cover all 4 item types (header, presentation, placeholder, cue)
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.txtCommit: 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
- RED: Write
-
5. PlaylistNode wrapper class (TDD)
What to do:
- RED: Write
php/tests/PlaylistNodeTest.phpfirst:getUuid: returns playlist node UUIDgetName: returns playlist name (e.g., "TestPlaylist", "03-01", "2026-02-07")getType: returns type string from proto enumgetEntries: returnsPlaylistEntry[]for leaf nodes (nodes withitems)getChildNodes: returnsPlaylistNode[]for container nodes (nodes withplaylists)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: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 withRv\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 oneofChildrenType:getPlaylists()returnsPlaylistArray(container),getItems()returnsPlaylistItems(leaf). UsegetChildrenType()to determine whichphp/generated/Rv/Data/Playlist/PlaylistArray.php—getPlaylists()returns repeated Playlistphp/generated/Rv/Data/Playlist/PlaylistItems.php—getItems()returns repeated PlaylistItemphp/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 PlaylistItemsPlaylist.phponeof ChildrenType: CRITICAL — must checkgetChildrenType()to know if node has child playlists or items. Wrong check = empty data
Acceptance Criteria:
php/tests/PlaylistNodeTest.phpexists with ≥8 test methodsphp/src/PlaylistNode.phpexists in namespaceProPresenter\Parsercd php && php vendor/bin/phpunit --filter PlaylistNodeTest— ALL tests pass- Container node returns child PlaylistNode objects
- 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.txtCommit: 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
- RED: Write
-
6. PlaylistArchive wrapper class (TDD)
What to do:
- RED: Write
php/tests/PlaylistArchiveTest.phpfirst: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 nodegetEntryCount: total items countgetApplicationInfo: returns app info string or objectgetType: returns document type (TYPE_PRESENTATION)getEmbeddedFiles: returns array of['filename' => string, 'data' => string]for all ZIP entries exceptdatagetEmbeddedProFiles: returns only .pro file entriesgetEmbeddedMediaFiles: returns only non-.pro, non-data entriesgetEmbeddedSong(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: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 playlistsphp/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()returnsPlaylist,getType()returns int,getApplicationInfo()returnsApplicationInfophp/src/PlaylistNode.php— Wrapper from Task 5 — used for root node and child nodesphp/src/PlaylistEntry.php— Wrapper from Task 4 — used via PlaylistNodephp/src/ProFileReader.php— Used ingetEmbeddedSong()to lazily parse .pro bytes. Important: ProFileReader reads from file path, so you need to use thePresentationproto directly:(new Presentation())->mergeFromString($bytes)thennew Song($presentation)
WHY Each Reference Matters:
Song.php: THIS is the class being mirrored. PlaylistArchive should feel identical in API stylePlaylistDocument.php: The exact proto API surface to delegate toProFileReader.php:33-36: Shows the proto->Song construction pattern. For lazy parsing, replicate this inline rather than going through file I/O
Acceptance Criteria:
php/tests/PlaylistArchiveTest.phpexists with ≥10 test methodsphp/src/PlaylistArchive.phpexists in namespaceProPresenter\Parsercd php && php vendor/bin/phpunit --filter PlaylistArchiveTest— ALL tests passgetEmbeddedSong()returns Song object via lazy parsinggetEmbeddedProFiles()andgetEmbeddedMediaFiles()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.txtCommit: 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
- RED: Write
-
7. ProPlaylistReader (TDD)
What to do:
- RED: Write
php/tests/ProPlaylistReaderTest.phpfirst:readThrowsOnMissingFile: nonexistent path → InvalidArgumentExceptionreadThrowsOnEmptyFile: 0-byte file → RuntimeExceptionreadThrowsOnInvalidZip: non-ZIP content → RuntimeExceptionreadLoadsTestPlaylist: readsref/TestPlaylist.proplaylist, verifies name, entry count, item typesreadLoadsGottesdienst: readsref/ExamplePlaylists/Gottesdienst.proplaylist, verifies it has >20 entriesreadExtractsEmbeddedProFiles: verifies.profiles are available viagetEmbeddedProFiles()readExtractsEmbeddedMediaFiles: verifies media files are availablereadParsesAllItemTypes: verifies headers, presentations, placeholders are correctly typedreadPreservesArrangementName: reads a file with arrangement, verifies field 5 valuereadHandlesAllExamplePlaylists: loop over all 4 test files, verify each loads without error
- GREEN: Implement
php/src/ProPlaylistReader.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: staticread(string $filePath)method, same validation (file exists, not empty), same exception types. The difference is ProPlaylistReader reads a ZIP instead of raw protobufphp/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 ZIPphp/src/PlaylistArchive.php— From Task 6: constructor takes(PlaylistDocument, array $embeddedFiles)— this is what we returnphp/generated/Rv/Data/PlaylistDocument.php—mergeFromString()to deserialize thedataentry- PHP
ZipArchive—open(),getFromName(),numFiles,getNameIndex(),close()
WHY Each Reference Matters:
ProFileReader.php: The TEMPLATE. ProPlaylistReader should be structurally identical, just with ZIP extraction addedZip64Fixer.php: MUST be called before ZipArchive::open() or it will fail with error 21PlaylistDocument::mergeFromString(): Thedataentry is raw protobuf bytes — deserialize with this method
Test Data:
ref/TestPlaylist.proplaylist— Small: 2 .pro files, 1 image, ~4 itemsref/ExamplePlaylists/Gottesdienst.proplaylist— Large: 14MB, 25+ itemsref/ExamplePlaylists/Gottesdienst 2.proplaylist— 10MBref/ExamplePlaylists/Gottesdienst 3.proplaylist— 16MB
Acceptance Criteria:
php/tests/ProPlaylistReaderTest.phpexists with ≥10 test methodsphp/src/ProPlaylistReader.phpexists with staticread()methodcd php && php vendor/bin/phpunit --filter ProPlaylistReaderTest— ALL tests pass- All 4 .proplaylist test files load successfully
- Embedded .pro files and media files are accessible from returned PlaylistArchive
- 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.txtCommit: 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
- RED: Write
-
8. ProPlaylistWriter (TDD)
What to do:
- RED: Write
php/tests/ProPlaylistWriterTest.phpfirst:writeThrowsOnMissingDirectory: target dir doesn't exist → InvalidArgumentExceptionwriteCreatesValidZipFile: write a PlaylistArchive, verify output is valid ZIPwriteContainsDataEntry: output ZIP hasdataentry with valid protobufwriteContainsEmbeddedProFiles: .pro files from archive are in ZIP at root levelwriteContainsEmbeddedMediaFiles: media files are in ZIP at their original pathswriteDeduplicatesFiles: same filename referenced twice → stored oncewriteDataEntryDeserializesToSameDocument: read backdataentry, compare with original
- GREEN: Implement
php/src/ProPlaylistWriter.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_STOREfor 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: staticwrite(Song, string)method, same validation, same exception types. The difference is writing ZIP instead of raw protobufphp/tests/ProFileWriterTest.php— Test pattern for writer tests
API/Type References:
php/src/PlaylistArchive.php—getDocument()returnsPlaylistDocumentfor serialization,getEmbeddedFiles()returns['filename' => bytes]map- PHP
ZipArchive—open()withZipArchive::CREATE,addFromString($name, $data),setCompressionName($name, ZipArchive::CM_STORE),close() PlaylistDocument::serializeToString()— Serializes proto to bytes for thedataentry
WHY Each Reference Matters:
ProFileWriter.php: Structural template — same validation pattern, same method signature stylePlaylistArchive: Source of ALL data to write — document fordataentry, embedded files for other entries
Acceptance Criteria:
php/tests/ProPlaylistWriterTest.phpexists with ≥7 test methodsphp/src/ProPlaylistWriter.phpexists with staticwrite()methodcd php && php vendor/bin/phpunit --filter ProPlaylistWriterTest— ALL tests pass- Written ZIP opens with standard
unzip -lwithout errors - 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.txtCommit: 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
- RED: Write
-
9. ProPlaylistGenerator (TDD)
What to do:
- RED: Write
php/tests/ProPlaylistGeneratorTest.phpfirst:generateCreatesPlaylistArchive: returns a PlaylistArchive objectgenerateSetsPlaylistName: verify archive name matches inputgenerateCreatesHeaders: header items with name and colorgenerateCreatesPresentationItems: presentation items with document path, arrangement UUID, arrangement namegenerateCreatesPlaceholders: placeholder itemsgenerateSetsApplicationInfo: application_info with platform and versiongenerateSetsTypePresentation: document type = TYPE_PRESENTATIONgenerateAndWriteCreatesFile: writes to disk and verifies readable
- GREEN: Implement
php/src/ProPlaylistGenerator.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']
- Header:
- Build proper protobuf structure: PlaylistDocument → root Playlist ("PLAYLIST") → child Playlist (name) → PlaylistItems → PlaylistItem[]
- Set application_info matching ProPresenter defaults (reuse
buildApplicationInfo()pattern fromProFileGenerator) - 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: staticgenerate()+generateAndWrite(), privatebuildApplicationInfo(),newUuid(),uuidFromString(),colorFromArray(). Consider reusing these methods or extracting to a trait/shared utilityphp/src/ProFileGenerator.php:55-60— Method signature pattern:generate(string $name, array $items, ...): Song. PlaylistGenerator follows this but returns PlaylistArchivephp/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 = 1php/generated/Rv/Data/Playlist.php— Node:setUuid(),setName(),setPlaylists(),setItems()php/generated/Rv/Data/Playlist/PlaylistArray.php—setPlaylists()takes array of Playlistphp/generated/Rv/Data/Playlist/PlaylistItems.php—setItems()takes array of PlaylistItemphp/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:
php/tests/ProPlaylistGeneratorTest.phpexists with ≥8 test methodsphp/src/ProPlaylistGenerator.phpexists withgenerate()andgenerateAndWrite()methodscd php && php vendor/bin/phpunit --filter ProPlaylistGeneratorTest— ALL tests pass- Generated playlist has correct structure: PlaylistDocument → root → child → items
- 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.txtCommit: 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
- RED: Write
-
10. CLI tool: parse-playlist.php
What to do:
- Create
php/bin/parse-playlist.phpfollowing the EXACT structure ofphp/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 Namewith color - Presentation:
[P] Song Title (arrangement: normal)with document path - Placeholder:
[-] Placeholder Name - Cue:
[C] Cue Name
- Header:
- Embedded .pro file list
- Embedded media file list
- Shebang line:
- 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)returnsPlaylistArchivephp/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:
php/bin/parse-playlist.phpexists with shebang linephp php/bin/parse-playlist.php(no args) → usage message + exit code 1php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist→ structured outputphp php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist→ output without errors- 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.txtCommit: 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
- Create
-
11. Round-trip integration tests
What to do:
- Add integration tests to
php/tests/ProPlaylistWriterTest.php(or a newphp/tests/ProPlaylistIntegrationTest.php):roundTripPreservesPlaylistName: read → write → read, compare namesroundTripPreservesEntryCount: same number of entries after round-triproundTripPreservesEntryTypes: all item types preserved (header/presentation/placeholder)roundTripPreservesArrangementNames: field 5 values survive round-triproundTripPreservesEmbeddedFileCount: same number of embedded filesroundTripPreservesDocumentPaths: presentation document_path URLs unchangedroundTripPreservesHeaderColors: header color RGBA values unchangedgeneratedPlaylistReadableByReader: generate → write → read back
- Use
ref/TestPlaylist.proplaylistas 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 patternsphp/tests/ProFileWriterTest.php— Shows how writer tests are structured
API/Type References:
php/src/ProPlaylistReader.php—read(string): PlaylistArchivephp/src/ProPlaylistWriter.php—write(PlaylistArchive, string): voidphp/src/ProPlaylistGenerator.php—generate(string, array): PlaylistArchivephp/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:
- Integration test file exists with ≥8 test methods
cd php && php vendor/bin/phpunit --filter Integration(or --filter ProPlaylistWriter) — ALL pass- Round-trip of TestPlaylist.proplaylist preserves all fields
- 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.txtCommit: 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
- Add integration tests to
-
12. Large-file / real-world validation tests
What to do:
- Add validation tests (in
php/tests/ProPlaylistIntegrationTest.phpor separate file):readsAllExamplePlaylistsWithoutError: loop over all 4 .proplaylist files, read each, assert no exceptionsgottesdienstHasExpectedStructure: the Gottesdienst playlists should have >20 entries, mix of headers and presentationsallPresentationItemsHaveDocumentPath: every presentation entry has a non-empty document pathembeddedProFilesExistForPresentations: for each presentation entry, verify corresponding .pro is in embedded filescliOutputMatchesReaderData: run parse-playlist.php, verify output entry count matches reader entry count
- Test all files in
ref/ExamplePlaylists/plusref/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 filesphp/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 filephp/src/PlaylistArchive.php— Getters for validation assertions
Test Data:
ref/TestPlaylist.proplaylistref/ExamplePlaylists/Gottesdienst.proplaylistref/ExamplePlaylists/Gottesdienst 2.proplaylistref/ExamplePlaylists/Gottesdienst 3.proplaylist
Acceptance Criteria:
- Validation tests exist with ≥5 test methods
cd php && php vendor/bin/phpunit— ALL tests pass including validation- 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.txtCommit: 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
- Add validation tests (in
-
13. AGENTS.md documentation update
What to do:
- Update
AGENTS.mdto document the new .proplaylist module, following the exact style of the existing.promodule documentation:- Add a new section for
.proplaylistfiles - 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')
- Reading:
- 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)
- Add a new section for
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:
AGENTS.mdcontains a new section for.proplaylistfiles- Section includes reading, writing, generating, CLI usage
- Section lists all new key files
- 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.txtCommit: YES
- Message:
docs(agents): update AGENTS.md with playlist module documentation - Files:
AGENTS.md - Pre-commit: —
- Update
Final Verification Wave
4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
-
F1. Plan Compliance Audit —
oracleRead 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 -
F2. Code Quality Review —
unspecified-highRunphp vendor/bin/phpunitfromphp/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 -
F3. Real Manual QA —
unspecified-highStart from clean state. Runphp 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 -
F4. Scope Fidelity Check —
deepFor 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
# 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
- All "Must Have" present
- All "Must NOT Have" absent
- All PHPUnit tests pass
- CLI tool handles all 4 test files
- Round-trip fidelity verified
- Format spec document complete
- AGENTS.md updated