propresenter-php/.sisyphus/plans/proplaylist-module.md
Thorsten Bus 157740c072 docs(playlist): add project completion summary and evidence files
- 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
2026-03-01 21:49:06 +01:00

74 KiB

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

  • php vendor/bin/phpunit from php/ directory — ALL tests pass (0 failures, 0 errors)
  • php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist — outputs structured playlist data
  • php 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 .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.

  • 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:

    • php/proto/playlist.proto contains string arrangement_name = 5; inside PlaylistItem.Presentation message
    • php/generated/Rv/Data/PlaylistItem/Presentation.php contains methods getArrangementName() and setArrangementName()
    • 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
  • 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:
      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:

    • php/tests/Zip64FixerTest.php exists with ≥6 test methods
    • php/src/Zip64Fixer.php exists with fix(string $zipData): string static method
    • cd 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.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
  • 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: PlaylistDocumentPlaylist (root_node) → PlaylistArray → child PlaylistPlaylistItemsPlaylistItem[]
      • 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.md exists
    • Document covers: container format, ZIP layout, protobuf structure, all item types, URL conventions
    • Document includes concrete examples
    • 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: —
  • 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:
      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.phpgetRed(), 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:

    • php/tests/PlaylistEntryTest.php exists with ≥10 test methods
    • php/src/PlaylistEntry.php exists in namespace ProPresenter\Parser
    • cd 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.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
  • 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:
      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.phpgetPlaylists() returns repeated Playlist
    • php/generated/Rv/Data/Playlist/PlaylistItems.phpgetItems() 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:

    • php/tests/PlaylistNodeTest.php exists with ≥8 test methods
    • php/src/PlaylistNode.php exists in namespace ProPresenter\Parser
    • cd 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.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
  • 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:
      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:

    • php/tests/PlaylistArchiveTest.php exists with ≥10 test methods
    • php/src/PlaylistArchive.php exists in namespace ProPresenter\Parser
    • cd php && php vendor/bin/phpunit --filter PlaylistArchiveTest — ALL tests pass
    • getEmbeddedSong() returns Song object via lazy parsing
    • 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
  • 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:
      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.phpmergeFromString() to deserialize the data entry
    • PHP ZipArchiveopen(), 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:

    • php/tests/ProPlaylistReaderTest.php exists with ≥10 test methods
    • php/src/ProPlaylistReader.php exists with static read() method
    • cd 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.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
  • 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:
      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.phpgetDocument() returns PlaylistDocument for serialization, getEmbeddedFiles() returns ['filename' => bytes] map
    • PHP ZipArchiveopen() 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:

    • php/tests/ProPlaylistWriterTest.php exists with ≥7 test methods
    • php/src/ProPlaylistWriter.php exists with static write() method
    • cd php && php vendor/bin/phpunit --filter ProPlaylistWriterTest — ALL tests pass
    • Written ZIP opens with standard unzip -l without 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.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
  • 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:
      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-149buildApplicationInfo() 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.phpTYPE_PRESENTATION = 1
    • php/generated/Rv/Data/Playlist.php — Node: setUuid(), setName(), setPlaylists(), setItems()
    • php/generated/Rv/Data/Playlist/PlaylistArray.phpsetPlaylists() takes array of Playlist
    • php/generated/Rv/Data/Playlist/PlaylistItems.phpsetItems() takes array of PlaylistItem
    • php/generated/Rv/Data/PlaylistItem.phpsetUuid(), setName(), setHeader() / setPresentation() / setPlaceholder()
    • php/generated/Rv/Data/PlaylistItem/Header.phpsetColor()
    • php/generated/Rv/Data/PlaylistItem/Presentation.phpsetDocumentPath(), 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.php exists with ≥8 test methods
    • php/src/ProPlaylistGenerator.php exists with generate() and generateAndWrite() methods
    • cd 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.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
  • 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.phpProPlaylistReader::read($filePath) returns PlaylistArchive
    • php/src/PlaylistArchive.phpgetName(), getEntries(), getEmbeddedProFiles(), getEmbeddedMediaFiles()
    • php/src/PlaylistEntry.phpgetType(), 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.php exists with shebang line
    • php php/bin/parse-playlist.php (no args) → usage message + exit code 1
    • php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist → structured output
    • php 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.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
  • 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.phpread(string): PlaylistArchive
    • php/src/ProPlaylistWriter.phpwrite(PlaylistArchive, string): void
    • php/src/ProPlaylistGenerator.phpgenerate(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:

    • 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.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
  • 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-78readLoadsDiverseReferenceFilesSuccessfully — this exact test pattern: loop over real files, verify each loads

    API/Type References:

    • php/src/ProPlaylistReader.phpread() 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:

    • 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.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
  • 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:

    • AGENTS.md contains a new section for .proplaylist files
    • 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.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.

  • F1. Plan Compliance Auditoracle 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

  • F2. Code Quality Reviewunspecified-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

  • F3. Real Manual QAunspecified-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

  • F4. Scope Fidelity Checkdeep 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

# 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