# ProPresenter .proplaylist PHP Module

## TL;DR

> **Quick Summary**: Build a complete PHP module for reading, writing, and generating ProPresenter 7 `.proplaylist` files. The format is a ZIP64 archive containing protobuf-encoded playlist metadata (`data` file), embedded `.pro` song files, and media files. Extends the existing `php/src/` parser codebase following the same static-factory + protobuf-wrapper patterns.
> 
> **Deliverables**:
> - Proto modification: add undocumented `arrangement_name` field 5 + regenerate PHP classes
> - ZIP64 patching utility: fix ProPresenter's broken ZIP headers for PHP ZipArchive compatibility
> - 3 wrapper classes: `PlaylistArchive`, `PlaylistNode`, `PlaylistEntry`
> - Reader: `ProPlaylistReader` — reads .proplaylist ZIP → wrapper objects
> - Writer: `ProPlaylistWriter` — serializes wrapper objects → .proplaylist ZIP
> - Generator: `ProPlaylistGenerator` — creates playlists from scratch
> - CLI tool: `php/bin/parse-playlist.php` — inspect playlist structure
> - Spec document: `spec/pp_playlist_spec.md` — format documentation
> - Full PHPUnit test suite: TDD for all components
> 
> **Estimated Effort**: Large
> **Parallel Execution**: YES — 4 waves
> **Critical Path**: Task 1 → Task 2 → Task 4 → Task 7 → Task 9 → Task 11 → Task 14

---

## Context

### Original Request
User wants to extend the existing ProPresenter PHP parser to support `.proplaylist` files — ZIP archives that bundle playlist metadata, `.pro` song files, and media files. This is a completely undocumented format that was reverse-engineered from 4 real files.

### Interview Summary
**Key Discussions**:
- **Scope**: Full Read + Write + Generate support
- **Testing**: TDD with PHPUnit (existing infrastructure: PHPUnit 11 with `#[Test]` attributes)
- **Field 5 handling**: Extend proto locally — add `string arrangement_name = 5;` to `PlaylistItem.Presentation`
- **Writer ZIP quirk**: Write clean, standard-compliant ZIP (don't reproduce PP's 98-byte quirk)
- **Embedded .pro parsing**: Raw bytes + lazy parsing — store bytes, provide `getEmbeddedSong()` on demand

**Research Findings**:
- No public documentation exists for .proplaylist format
- Format is ZIP64 with `store` compression; `data` file contains protobuf `PlaylistDocument`
- ProPresenter exports ZIP with consistently broken Central Directory size fields (off by 98 bytes)
- PHP's ZipArchive rejects these files (error 21 = ER_INCONS); need patching before reading
- Undocumented field 5 on `PlaylistItem.Presentation` stores arrangement name as string
- All proto classes already generated in `php/generated/Rv/Data/`
- URL paths use `ROOT_USER_HOME (2)` for personal paths, `ROOT_SHOW (10)` for PP library paths

### Metis Review
**Identified Gaps** (addressed):
- Wrapper class naming collision: Use `PlaylistArchive`, `PlaylistNode`, `PlaylistEntry` (not `Playlist`/`PlaylistItem`)
- Proto regeneration: Must regenerate after adding field 5 — verified existing Makefile/script patterns
- ZIP64 patching: Must patch both ZIP64 EOCD (offset +40) and regular EOCD (offset +12) — validated algorithm
- Test data paths: Use `dirname(__DIR__, 2) . '/ref/...'` pattern matching existing tests

---

## Work Objectives

### Core Objective
Implement a complete, tested PHP module that can read, write, and generate ProPresenter 7 `.proplaylist` files, following the same architecture patterns as the existing `.pro` file parser.

### Concrete Deliverables
- Modified `php/proto/playlist.proto` with field 5 added
- Regenerated PHP classes in `php/generated/`
- `php/src/Zip64Fixer.php` — ZIP64 header patching utility
- `php/src/PlaylistArchive.php` — top-level playlist wrapper (like `Song.php`)
- `php/src/PlaylistNode.php` — playlist/folder node wrapper (like `Group.php`)
- `php/src/PlaylistEntry.php` — playlist item wrapper (header/presentation/placeholder/cue)
- `php/src/ProPlaylistReader.php` — reads .proplaylist files (like `ProFileReader.php`)
- `php/src/ProPlaylistWriter.php` — writes .proplaylist files (like `ProFileWriter.php`)
- `php/src/ProPlaylistGenerator.php` — generates playlists from scratch (like `ProFileGenerator.php`)
- `php/bin/parse-playlist.php` — CLI tool (like `php/bin/parse-song.php`)
- `spec/pp_playlist_spec.md` — format specification (like `spec/pp_song_spec.md`)
- `php/tests/Zip64FixerTest.php` — tests for ZIP patching
- `php/tests/PlaylistArchiveTest.php` — tests for wrapper class
- `php/tests/PlaylistNodeTest.php` — tests for node wrapper
- `php/tests/PlaylistEntryTest.php` — tests for entry wrapper
- `php/tests/ProPlaylistReaderTest.php` — tests for reader
- `php/tests/ProPlaylistWriterTest.php` — tests for writer
- `php/tests/ProPlaylistGeneratorTest.php` — tests for generator

### Definition of Done
- [x] `php vendor/bin/phpunit` from `php/` directory — ALL tests pass (0 failures, 0 errors)
- [x] `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` — outputs structured playlist data
- [x] `php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist` — handles large real-world file
- [x] Round-trip: read → write → read produces identical data
- [x] Generated playlist can be read back by the reader

### Must Have
- ZIP64 patching that handles ProPresenter's broken archives
- All 4 PlaylistItem types: header, presentation, placeholder, cue
- Arrangement name (field 5) read/write support
- Embedded .pro file access (raw bytes + lazy Song parsing)
- Media file access from ZIP
- All URL root types (ROOT_USER_HOME, ROOT_SHOW)
- CLI tool with structured output matching parse-song.php style
- Format specification document

### Must NOT Have (Guardrails)
- Do NOT modify generated proto PHP classes directly — only modify `.proto` source and regenerate
- Do NOT name wrapper classes `Playlist` or `PlaylistItem` — collision with `Rv\Data\Playlist` / `Rv\Data\PlaylistItem`
- Do NOT auto-parse embedded .pro files during read — use lazy loading only
- Do NOT reproduce ProPresenter's 98-byte ZIP quirk in writer — write clean, standard ZIP
- Do NOT add audio playlist support (TYPE_AUDIO) — out of scope
- Do NOT add PlanningCenter integration — out of scope
- Do NOT add smart directory support — out of scope
- Do NOT create abstract base classes or over-abstract — follow the flat, concrete style of existing code

---

## Verification Strategy

> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.

### Test Decision
- **Infrastructure exists**: YES
- **Automated tests**: TDD (RED → GREEN → REFACTOR)
- **Framework**: PHPUnit 11 with `#[Test]` attributes
- **Each task**: Write failing test first, then implement until tests pass

### QA Policy
Every task MUST include agent-executed QA scenarios.
Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`.

- **PHP Module**: Use Bash — `php vendor/bin/phpunit --filter ClassName` or inline PHP execution
- **CLI Tool**: Use Bash — run CLI command, verify output format
- **Proto Regen**: Use Bash — verify classes exist and contain expected methods

---

## Execution Strategy

### Parallel Execution Waves

```
Wave 1 (Foundation — start immediately, all independent):
├── Task 1: Proto modification + regeneration [quick]
├── Task 2: Zip64Fixer utility (TDD) [deep]
└── Task 3: Format specification document [writing]

Wave 2 (Wrapper classes — after Wave 1, all parallel):
├── Task 4: PlaylistEntry wrapper (TDD) [unspecified-high]
├── Task 5: PlaylistNode wrapper (TDD) [unspecified-high]
└── Task 6: PlaylistArchive wrapper (TDD) [unspecified-high]

Wave 3 (Reader/Writer/Generator — after Wave 2):
├── Task 7: ProPlaylistReader (TDD) [deep]
├── Task 8: ProPlaylistWriter (TDD) [deep]
├── Task 9: ProPlaylistGenerator (TDD) [deep]
└── Task 10: CLI tool parse-playlist.php [quick]

Wave 4 (Integration + verification — after Wave 3):
├── Task 11: Round-trip integration tests [deep]
├── Task 12: Large-file / real-world validation [unspecified-high]
└── Task 13: AGENTS.md documentation update [quick]

Wave FINAL (After ALL tasks — independent review, 4 parallel):
├── Task F1: Plan compliance audit (oracle)
├── Task F2: Code quality review (unspecified-high)
├── Task F3: Real manual QA (unspecified-high)
└── Task F4: Scope fidelity check (deep)

Critical Path: Task 1 → Task 2 → Task 4 → Task 7 → Task 9 → Task 11 → F1-F4
Parallel Speedup: ~60% faster than sequential
Max Concurrent: 3 (Waves 1, 2, 3)
```

### Dependency Matrix

| Task | Depends On | Blocks | Wave |
|------|-----------|--------|------|
| 1 | — | 4, 5, 6 | 1 |
| 2 | — | 7, 8 | 1 |
| 3 | — | — | 1 |
| 4 | 1 | 5, 6, 7, 8, 9 | 2 |
| 5 | 1, 4 | 6, 7, 8, 9 | 2 |
| 6 | 1, 4, 5 | 7, 8, 9, 10 | 2 |
| 7 | 2, 6 | 8, 10, 11, 12 | 3 |
| 8 | 6, 7 | 11, 12 | 3 |
| 9 | 6 | 11 | 3 |
| 10 | 7 | 12 | 3 |
| 11 | 7, 8, 9 | — | 4 |
| 12 | 7, 10 | — | 4 |
| 13 | 7, 10 | — | 4 |
| F1-F4 | ALL | — | FINAL |

### Agent Dispatch Summary

- **Wave 1**: 3 tasks — T1 → `quick`, T2 → `deep`, T3 → `writing`
- **Wave 2**: 3 tasks — T4 → `unspecified-high`, T5 → `unspecified-high`, T6 → `unspecified-high`
- **Wave 3**: 4 tasks — T7 → `deep`, T8 → `deep`, T9 → `deep`, T10 → `quick`
- **Wave 4**: 3 tasks — T11 → `deep`, T12 → `unspecified-high`, T13 → `quick`
- **FINAL**: 4 tasks — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep`

---

## TODOs

> Implementation + Test = ONE Task. Never separate.
> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.


- [x] 1. Proto modification: Add arrangement_name field 5 + regenerate PHP classes

  **What to do**:
  - Edit `php/proto/playlist.proto`: in the `PlaylistItem.Presentation` message (line ~89-94), add `string arrangement_name = 5;` after the `user_music_key` field
  - Regenerate PHP protobuf classes by running `protoc` with the same flags used for the existing generation. Check if there's a Makefile or shell script in `php/` or project root. If not, run:
    ```
    protoc --php_out=php/generated --proto_path=php/proto php/proto/*.proto
    ```
  - Verify the regenerated `php/generated/Rv/Data/PlaylistItem/Presentation.php` now has `getArrangementName()` and `setArrangementName()` methods
  - Run existing tests to ensure nothing broke: `cd php && php vendor/bin/phpunit`

  **Must NOT do**:
  - Do NOT manually edit any file in `php/generated/` — only modify `.proto` source
  - Do NOT change any existing field numbers or types
  - Do NOT add fields beyond `arrangement_name = 5`

  **Recommended Agent Profile**:
  - **Category**: `quick`
    - Reason: Single proto file edit + running a command to regenerate
  - **Skills**: []
    - No special skills needed — file edit + bash command

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 1 (with Tasks 2, 3)
  - **Blocks**: Tasks 4, 5, 6 (wrapper classes need the updated proto)
  - **Blocked By**: None (can start immediately)

  **References**:

  **Pattern References**:
  - `php/proto/playlist.proto:89-94` — The `PlaylistItem.Presentation` message where field 5 must be added. Currently has fields 1-4 (document_path, arrangement, content_destination, user_music_key)
  - `php/generated/Rv/Data/PlaylistItem/Presentation.php` — The generated class that will be regenerated. Currently has getters/setters for fields 1-4 only

  **API/Type References**:
  - `php/generated/Rv/Data/PlaylistDocument.php` — Top-level message class (should NOT change)
  - `php/generated/Rv/Data/PlaylistItem.php` — Parent class with oneof ItemType (should gain no new fields)

  **External References**:
  - Protobuf proto3 syntax: field 5 is `string arrangement_name = 5;`

  **WHY Each Reference Matters**:
  - `playlist.proto:89-94`: This is the EXACT location to add the field. The `PlaylistItem.Presentation` message currently ends at field 4. Field 5 is undocumented in the community proto but observed in every real-world .proplaylist file holding an arrangement name string like "normal", "bene", "test2"
  - The generated Presentation.php: After regeneration, verify this file has the new methods — that's the acceptance criterion

  **Acceptance Criteria**:
  - [x] `php/proto/playlist.proto` contains `string arrangement_name = 5;` inside `PlaylistItem.Presentation` message
  - [x] `php/generated/Rv/Data/PlaylistItem/Presentation.php` contains methods `getArrangementName()` and `setArrangementName()`
  - [x] `cd php && php vendor/bin/phpunit` — ALL existing tests still pass (0 failures)

  **QA Scenarios:**

  ```
  Scenario: Proto field 5 present in source
    Tool: Bash (grep)
    Preconditions: Task 1 complete
    Steps:
      1. grep 'arrangement_name' php/proto/playlist.proto
      2. Assert output contains 'string arrangement_name = 5;'
    Expected Result: Exactly one match in PlaylistItem.Presentation message
    Failure Indicators: No match, or field number != 5
    Evidence: .sisyphus/evidence/task-1-proto-field.txt

  Scenario: Generated PHP class has new methods
    Tool: Bash (grep)
    Preconditions: Proto regeneration complete
    Steps:
      1. grep -c 'getArrangementName\|setArrangementName' php/generated/Rv/Data/PlaylistItem/Presentation.php
      2. Assert count >= 2
    Expected Result: At least 2 matches (getter + setter)
    Failure Indicators: 0 matches means regeneration failed or field not added
    Evidence: .sisyphus/evidence/task-1-generated-methods.txt

  Scenario: Existing tests still pass
    Tool: Bash
    Preconditions: Regeneration complete
    Steps:
      1. cd php && php vendor/bin/phpunit
      2. Assert exit code 0
      3. Assert output contains 'OK'
    Expected Result: All existing tests pass with 0 failures
    Failure Indicators: Any test failure or error
    Evidence: .sisyphus/evidence/task-1-existing-tests.txt
  ```

  **Commit**: YES
  - Message: `feat(proto): add arrangement_name field 5 to PlaylistItem.Presentation`
  - Files: `php/proto/playlist.proto`, `php/generated/**`
  - Pre-commit: `cd php && php vendor/bin/phpunit`

- [x] 2. Zip64Fixer utility (TDD)

  **What to do**:
  - **RED**: Write `php/tests/Zip64FixerTest.php` first with these test cases:
    - `fixReturnsValidZipForProPresenterExport`: Pass `ref/TestPlaylist.proplaylist` bytes → output should open with `ZipArchive::open()` without errors
    - `fixReturnsValidZipForLargePlaylist`: Pass `ref/ExamplePlaylists/Gottesdienst.proplaylist` → same
    - `fixThrowsOnNonZipData`: Pass random bytes → expect RuntimeException
    - `fixThrowsOnTooSmallData`: Pass 10 bytes → expect RuntimeException
    - `fixPreservesAllEntries`: After fix, ZipArchive lists same entries as `unzip -l`
    - `fixIdempotent`: Fixing an already-fixed file produces identical output
  - **GREEN**: Implement `php/src/Zip64Fixer.php` with a single static method:
    ```php
    final class Zip64Fixer {
        public static function fix(string $zipData): string
    }
    ```
  - Algorithm:
    1. Find EOCD signature (`\x50\x4b\x05\x06`) scanning from end of file
    2. Read CD offset from EOCD (offset +16, 4 bytes, little-endian)
    3. If CD offset == 0xFFFFFFFF, find ZIP64 EOCD locator (signature `\x50\x4b\x06\x07`), then ZIP64 EOCD (signature `\x50\x4b\x06\x06`)
    4. From ZIP64 EOCD: read CD offset (offset +48, 8 bytes LE) and zip64_eocd_position
    5. Calculate `correct_cd_size = zip64_eocd_position - cd_offset`
    6. Patch ZIP64 EOCD field at offset +40 (8 bytes LE) with correct_cd_size
    7. Patch regular EOCD field at offset +12 (4 bytes LE) with `min(correct_cd_size, 0xFFFFFFFF)`
    8. Return patched bytes
  - **REFACTOR**: Extract constants for magic numbers, add doc comments

  **Must NOT do**:
  - Do NOT modify the original file on disk — work on bytes in memory
  - Do NOT decompress/recompress — only patch header fields
  - Do NOT use external ZIP repair tools

  **Recommended Agent Profile**:
  - **Category**: `deep`
    - Reason: Binary format parsing requires careful byte-level work and thorough testing
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 1 (with Tasks 1, 3)
  - **Blocks**: Tasks 7, 8 (Reader/Writer need the fixer)
  - **Blocked By**: None (can start immediately)

  **References**:

  **Pattern References**:
  - `php/src/ProFileReader.php:13-37` — Static factory pattern with validation/error handling. Follow the same `InvalidArgumentException`/`RuntimeException` pattern
  - `php/tests/ProFileReaderTest.php` — Test structure with `#[Test]` attributes, `dirname(__DIR__, 2)` for test data paths

  **API/Type References**:
  - PHP `ZipArchive` class — Used to verify the fix works. `ZipArchive::open()` returns `true` on success, error code on failure. Error 21 = `ER_INCONS` (the broken state)

  **External References**:
  - ZIP64 format: EOCD at end of file, signature `0x06054b50`. ZIP64 EOCD signature `0x06064b50`. ZIP64 EOCD locator `0x07064b50`
  - The 98-byte discrepancy: ProPresenter writes CD size field as `actual_cd_size + 98` consistently across all exported files

  **WHY Each Reference Matters**:
  - `ProFileReader.php`: Shows the exact error handling pattern (exception types, message formatting) to replicate
  - `ProFileReaderTest.php`: Shows the exact test structure (namespace, imports, `#[Test]` attribute, `dirname(__DIR__, 2)` for paths) to replicate
  - ZIP64 specification: The byte offsets and signatures are critical — getting any offset wrong by 1 byte breaks everything

  **Test Data**:
  - `ref/TestPlaylist.proplaylist` — small file, fast to test
  - `ref/ExamplePlaylists/Gottesdienst.proplaylist` — 14MB real-world file

  **Acceptance Criteria**:
  - [x] `php/tests/Zip64FixerTest.php` exists with ≥6 test methods
  - [x] `php/src/Zip64Fixer.php` exists with `fix(string $zipData): string` static method
  - [x] `cd php && php vendor/bin/phpunit --filter Zip64FixerTest` — ALL tests pass
  - [x] Fixed ZIP data opens with `new ZipArchive()` without errors for all 4 test files

  **QA Scenarios:**

  ```
  Scenario: Fix and open TestPlaylist
    Tool: Bash
    Preconditions: Zip64Fixer implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$data = file_get_contents('ref/TestPlaylist.proplaylist');
         \$fixed = ProPresenter\Parser\Zip64Fixer::fix(\$data);
         \$tmp = tempnam(sys_get_temp_dir(), 'zip');
         file_put_contents(\$tmp, \$fixed);
         \$za = new ZipArchive();
         \$result = \$za->open(\$tmp);
         echo \$result === true ? 'OK' : 'FAIL: ' . \$result;
         unlink(\$tmp);
         "
      2. Assert output is 'OK'
    Expected Result: ZipArchive opens without error
    Failure Indicators: Output contains 'FAIL' or any error code
    Evidence: .sisyphus/evidence/task-2-fix-open.txt

  Scenario: Error on invalid input
    Tool: Bash
    Preconditions: Zip64Fixer implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         try { ProPresenter\Parser\Zip64Fixer::fix('not a zip'); echo 'NO_EXCEPTION'; }
         catch (RuntimeException \$e) { echo 'OK: ' . \$e->getMessage(); }"
      2. Assert output starts with 'OK:'
    Expected Result: RuntimeException thrown for invalid data
    Failure Indicators: Output is 'NO_EXCEPTION'
    Evidence: .sisyphus/evidence/task-2-error-handling.txt
  ```

  **Commit**: YES
  - Message: `feat(playlist): add Zip64Fixer for ProPresenter ZIP archives`
  - Files: `php/src/Zip64Fixer.php`, `php/tests/Zip64FixerTest.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit --filter Zip64FixerTest`

- [x] 3. Format specification document

  **What to do**:
  - Create `spec/pp_playlist_spec.md` documenting the .proplaylist file format
  - Follow the structure and style of `spec/pp_song_spec.md`
  - Document:
    - Container format: ZIP64 archive, store compression, ZIP64 EOCD quirk
    - ZIP entry layout: `data` file at root, `.pro` files at root, media files at original paths
    - Protobuf structure: `PlaylistDocument` → `Playlist` (root_node) → `PlaylistArray` → child `Playlist` → `PlaylistItems` → `PlaylistItem[]`
    - All PlaylistItem types: Header (field 3), Presentation (field 4), Cue (field 5), PlanningCenter (field 6), Placeholder (field 8)
    - Presentation details: document_path URL, arrangement UUID, arrangement_name (field 5, undocumented), user_music_key
    - URL root types: ROOT_USER_HOME (2), ROOT_SHOW (10)
    - Deduplication rules: same .pro stored once, media files deduplicated
    - Known values: application_info, type = TYPE_PRESENTATION, root always named "PLAYLIST"
  - Include concrete examples from the reverse-engineered data

  **Must NOT do**:
  - Do NOT include raw protobuf hex dumps — use structured descriptions
  - Do NOT speculate about unobserved features — document only what was verified

  **Recommended Agent Profile**:
  - **Category**: `writing`
    - Reason: Technical documentation task
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 1 (with Tasks 1, 2)
  - **Blocks**: Nothing (informational document)
  - **Blocked By**: None

  **References**:

  **Pattern References**:
  - `spec/pp_song_spec.md` — Follow the EXACT same document structure, heading hierarchy, and level of detail. This is the template for the playlist spec

  **External References**:
  - Draft analysis: `.sisyphus/drafts/proplaylist-format.md` — Contains all reverse-engineered findings. Copy relevant sections and restructure into spec format
  - Proto definition: `php/proto/playlist.proto` — Authoritative message structure

  **WHY Each Reference Matters**:
  - `pp_song_spec.md`: Establishes the documentation standard for this project. The playlist spec must feel like it belongs alongside the song spec
  - Draft analysis: Contains ALL the raw findings from reverse-engineering. The spec document restructures this into a clean, permanent reference

  **Acceptance Criteria**:
  - [x] `spec/pp_playlist_spec.md` exists
  - [x] Document covers: container format, ZIP layout, protobuf structure, all item types, URL conventions
  - [x] Document includes concrete examples
  - [x] Follows `spec/pp_song_spec.md` style

  **QA Scenarios:**

  ```
  Scenario: Spec document exists and covers key sections
    Tool: Bash (grep)
    Preconditions: Task 3 complete
    Steps:
      1. test -f spec/pp_playlist_spec.md && echo EXISTS
      2. grep -c 'PlaylistDocument\|PlaylistItem\|ZIP64\|arrangement_name\|ROOT_USER_HOME\|Header\|Presentation\|Placeholder' spec/pp_playlist_spec.md
    Expected Result: File exists, grep count >= 8 (all key terms present)
    Failure Indicators: File missing or key terms not mentioned
    Evidence: .sisyphus/evidence/task-3-spec-coverage.txt
  ```

  **Commit**: YES
  - Message: `docs(spec): add .proplaylist format specification`
  - Files: `spec/pp_playlist_spec.md`
  - Pre-commit: —

- [x] 4. PlaylistEntry wrapper class (TDD)

  **What to do**:
  - **RED**: Write `php/tests/PlaylistEntryTest.php` first:
    - `getUuid`: returns item UUID string
    - `getName`: returns item name string
    - `getType`: returns 'header' | 'presentation' | 'placeholder' | 'cue' | 'unknown'
    - `isHeader` / `isPresentation` / `isPlaceholder` / `isCue`: boolean type checks
    - For header items: `getHeaderColor()` returns `[r, g, b, a]` array
    - For presentation items: `getDocumentPath()`, `getArrangementUuid()`, `getArrangementName()`, `hasArrangement()`
    - For presentation items: `getDocumentFilename()` — extracts filename from URL path (e.g., `Song.pro` from full URL)
    - Test with manually constructed protobuf objects (no file I/O needed)
  - **GREEN**: Implement `php/src/PlaylistEntry.php`:
    ```php
    namespace ProPresenter\Parser;
    use Rv\Data\PlaylistItem;
    
    class PlaylistEntry {
        public function __construct(private readonly PlaylistItem $item) {}
        public function getUuid(): string
        public function getName(): string
        public function getType(): string  // 'header'|'presentation'|'placeholder'|'cue'|'unknown'
        public function isHeader(): bool
        public function isPresentation(): bool
        public function isPlaceholder(): bool
        public function isCue(): bool
        public function getHeaderColor(): ?array  // [r,g,b,a] or null
        public function getDocumentPath(): ?string  // absolute URL string
        public function getDocumentFilename(): ?string  // just the filename
        public function getArrangementUuid(): ?string
        public function getArrangementName(): ?string  // field 5
        public function hasArrangement(): bool
        public function getPlaylistItem(): PlaylistItem  // raw proto access
    }
    ```
  - **REFACTOR**: Ensure clean null handling, extract repetitive patterns

  **Must NOT do**:
  - Do NOT name this class `PlaylistItem` — collision with `Rv\Data\PlaylistItem`
  - Do NOT add file I/O — this is a pure data wrapper
  - Do NOT parse embedded .pro files here — that's the reader's job

  **Recommended Agent Profile**:
  - **Category**: `unspecified-high`
    - Reason: Medium complexity wrapper with multiple type branches and TDD workflow
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 2 (with Tasks 5, 6)
  - **Blocks**: Tasks 5, 6, 7, 8, 9
  - **Blocked By**: Task 1 (needs regenerated proto with field 5)

  **References**:

  **Pattern References**:
  - `php/src/Slide.php` — Follow this wrapper pattern: constructor takes proto object, provides typed getters, exposes raw proto via `getX()` method. The Slide class wraps a Cue protobuf the same way PlaylistEntry wraps PlaylistItem
  - `php/src/Group.php` — Another wrapper example: simple constructor + getters for UUID, name, plus derived data
  - `php/tests/SlideTest.php` — Test pattern: construct proto objects manually in test, wrap them, assert getter results

  **API/Type References**:
  - `php/generated/Rv/Data/PlaylistItem.php` — The proto class being wrapped. Key methods: `getItemType()` returns string from oneof ('header'|'presentation'|'cue'|'planning_center'|'placeholder'), `getHeader()`, `getPresentation()`, `getPlaceholder()`, `getCue()`
  - `php/generated/Rv/Data/PlaylistItem/Presentation.php` — Presentation sub-message: `getDocumentPath()`, `getArrangement()`, `getArrangementName()`, `getUserMusicKey()`
  - `php/generated/Rv/Data/PlaylistItem/Header.php` — Header sub-message: `getColor()` returns `Rv\Data\Color`
  - `php/generated/Rv/Data/Color.php` — `getRed()`, `getGreen()`, `getBlue()`, `getAlpha()` all return float

  **WHY Each Reference Matters**:
  - `Slide.php` + `Group.php`: These ARE the pattern. PlaylistEntry must look and feel like these classes — same constructor style, same getter conventions, same raw-proto-access method
  - `PlaylistItem.php`: The protobuf API surface — you need to know the exact method names to delegate to
  - `SlideTest.php`: Shows how to construct proto objects in tests without file I/O

  **Acceptance Criteria**:
  - [x] `php/tests/PlaylistEntryTest.php` exists with ≥10 test methods
  - [x] `php/src/PlaylistEntry.php` exists in namespace `ProPresenter\Parser`
  - [x] `cd php && php vendor/bin/phpunit --filter PlaylistEntryTest` — ALL tests pass
  - [x] Tests cover all 4 item types (header, presentation, placeholder, cue)
  - [x] `getArrangementName()` returns field 5 value for presentation items

  **QA Scenarios:**

  ```
  Scenario: Create a presentation entry and read arrangement name
    Tool: Bash
    Preconditions: PlaylistEntry + proto field 5 implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$pres = new Rv\Data\PlaylistItem\Presentation();
         \$pres->setArrangementName('test-arrangement');
         \$item = new Rv\Data\PlaylistItem();
         \$item->setPresentation(\$pres);
         \$item->setName('TestSong');
         \$entry = new ProPresenter\Parser\PlaylistEntry(\$item);
         echo \$entry->getArrangementName();
         "
      2. Assert output is 'test-arrangement'
    Expected Result: 'test-arrangement' printed
    Failure Indicators: Empty output, exception, or wrong value
    Evidence: .sisyphus/evidence/task-4-arrangement-name.txt

  Scenario: Type detection for header item
    Tool: Bash
    Preconditions: PlaylistEntry implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$header = new Rv\Data\PlaylistItem\Header();
         \$item = new Rv\Data\PlaylistItem();
         \$item->setHeader(\$header);
         \$entry = new ProPresenter\Parser\PlaylistEntry(\$item);
         echo \$entry->getType() . ' ' . (\$entry->isHeader() ? 'YES' : 'NO');
         "
      2. Assert output is 'header YES'
    Expected Result: Type correctly identified as 'header'
    Failure Indicators: Wrong type string or isHeader() returns false
    Evidence: .sisyphus/evidence/task-4-type-detection.txt
  ```

  **Commit**: YES (groups with T5, T6)
  - Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
  - Files: `php/src/PlaylistEntry.php`, `php/tests/PlaylistEntryTest.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistEntryTest`

- [x] 5. PlaylistNode wrapper class (TDD)

  **What to do**:
  - **RED**: Write `php/tests/PlaylistNodeTest.php` first:
    - `getUuid`: returns playlist node UUID
    - `getName`: returns playlist name (e.g., "TestPlaylist", "03-01", "2026-02-07")
    - `getType`: returns type string from proto enum
    - `getEntries`: returns `PlaylistEntry[]` for leaf nodes (nodes with `items`)
    - `getChildNodes`: returns `PlaylistNode[]` for container nodes (nodes with `playlists`)
    - `isContainer`: true if node has child playlists (PlaylistArray)
    - `isLeaf`: true if node has items (PlaylistItems)
    - `getEntryCount`: returns number of items
    - Test with manually constructed proto Playlist objects
  - **GREEN**: Implement `php/src/PlaylistNode.php`:
    ```php
    namespace ProPresenter\Parser;
    use Rv\Data\Playlist;
    
    class PlaylistNode {
        private array $entries = [];
        private array $childNodes = [];
        
        public function __construct(private readonly Playlist $playlist) {
            // Parse items into PlaylistEntry objects
            // Parse child playlists into PlaylistNode objects
        }
        public function getUuid(): string
        public function getName(): string
        public function getType(): int
        public function getEntries(): array  // PlaylistEntry[]
        public function getChildNodes(): array  // PlaylistNode[]
        public function isContainer(): bool
        public function isLeaf(): bool
        public function getEntryCount(): int
        public function getPlaylist(): Playlist  // raw proto access
    }
    ```
  - **REFACTOR**: Clean up, ensure consistent null safety

  **Must NOT do**:
  - Do NOT name this class `Playlist` — collision with `Rv\Data\Playlist`
  - Do NOT add any file I/O
  - Do NOT try to resolve document paths to actual files

  **Recommended Agent Profile**:
  - **Category**: `unspecified-high`
    - Reason: Medium complexity wrapper with recursive tree structure
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 2 (with Tasks 4, 6)
  - **Blocks**: Tasks 6, 7, 8, 9
  - **Blocked By**: Tasks 1, 4 (needs proto + PlaylistEntry)

  **References**:

  **Pattern References**:
  - `php/src/Song.php:21-51` — Constructor pattern: takes proto object, iterates children to build typed wrapper arrays. Song iterates CueGroups to build Group[] and Cues to build Slide[] — PlaylistNode iterates PlaylistItems to build PlaylistEntry[]
  - `php/src/Group.php` — Simple wrapper pattern with UUID + name getters

  **API/Type References**:
  - `php/generated/Rv/Data/Playlist.php` — The proto class being wrapped. Key oneof `ChildrenType`: `getPlaylists()` returns `PlaylistArray` (container), `getItems()` returns `PlaylistItems` (leaf). Use `getChildrenType()` to determine which
  - `php/generated/Rv/Data/Playlist/PlaylistArray.php` — `getPlaylists()` returns repeated Playlist
  - `php/generated/Rv/Data/Playlist/PlaylistItems.php` — `getItems()` returns repeated PlaylistItem
  - `php/src/PlaylistEntry.php` — The wrapper created in Task 4, used to wrap each PlaylistItem

  **WHY Each Reference Matters**:
  - `Song.php:21-51`: The constructor loop pattern — iterating proto repeated fields and wrapping each into domain objects. PlaylistNode does the same thing with PlaylistItems
  - `Playlist.php` oneof ChildrenType: CRITICAL — must check `getChildrenType()` to know if node has child playlists or items. Wrong check = empty data

  **Acceptance Criteria**:
  - [x] `php/tests/PlaylistNodeTest.php` exists with ≥8 test methods
  - [x] `php/src/PlaylistNode.php` exists in namespace `ProPresenter\Parser`
  - [x] `cd php && php vendor/bin/phpunit --filter PlaylistNodeTest` — ALL tests pass
  - [x] Container node returns child PlaylistNode objects
  - [x] Leaf node returns PlaylistEntry objects

  **QA Scenarios:**

  ```
  Scenario: Leaf node with items returns entries
    Tool: Bash
    Preconditions: PlaylistNode + PlaylistEntry implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$item = new Rv\Data\PlaylistItem();
         \$item->setName('TestItem');
         \$items = new Rv\Data\Playlist\PlaylistItems();
         \$items->setItems([\$item]);
         \$playlist = new Rv\Data\Playlist();
         \$playlist->setName('TestPlaylist');
         \$playlist->setItems(\$items);
         \$node = new ProPresenter\Parser\PlaylistNode(\$playlist);
         echo \$node->getName() . ' ' . \$node->getEntryCount() . ' ' . (\$node->isLeaf() ? 'LEAF' : 'CONTAINER');
         "
      2. Assert output is 'TestPlaylist 1 LEAF'
    Expected Result: Node correctly identified as leaf with 1 entry
    Failure Indicators: Wrong count or wrong type detection
    Evidence: .sisyphus/evidence/task-5-leaf-node.txt
  ```

  **Commit**: YES (groups with T4, T6)
  - Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
  - Files: `php/src/PlaylistNode.php`, `php/tests/PlaylistNodeTest.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistNodeTest`

- [x] 6. PlaylistArchive wrapper class (TDD)

  **What to do**:
  - **RED**: Write `php/tests/PlaylistArchiveTest.php` first:
    - `getName`: returns the child playlist name (e.g., "TestPlaylist")
    - `getRootNode`: returns the root PlaylistNode (always named "PLAYLIST")
    - `getPlaylistNode`: returns the first child node (the actual named playlist)
    - `getEntries`: shortcut to get all entries from the playlist node
    - `getEntryCount`: total items count
    - `getApplicationInfo`: returns app info string or object
    - `getType`: returns document type (TYPE_PRESENTATION)
    - `getEmbeddedFiles`: returns array of `['filename' => string, 'data' => string]` for all ZIP entries except `data`
    - `getEmbeddedProFiles`: returns only .pro file entries
    - `getEmbeddedMediaFiles`: returns only non-.pro, non-data entries
    - `getEmbeddedSong(string $filename)`: lazy-parses a .pro file into a Song object via ProFileReader
    - Test with manually constructed proto objects (no file I/O in unit tests)
  - **GREEN**: Implement `php/src/PlaylistArchive.php`:
    ```php
    namespace ProPresenter\Parser;
    use Rv\Data\PlaylistDocument;
    
    class PlaylistArchive {
        private PlaylistNode $rootNode;
        private array $embeddedFiles = [];  // filename => raw bytes
        private array $parsedSongs = [];  // filename => Song (lazy cache)
        
        public function __construct(
            private readonly PlaylistDocument $document,
            array $embeddedFiles = [],
        ) {}
        
        public function getName(): string  // child playlist name
        public function getRootNode(): PlaylistNode
        public function getPlaylistNode(): ?PlaylistNode  // first child
        public function getEntries(): array  // PlaylistEntry[]
        public function getEntryCount(): int
        public function getType(): int
        public function getEmbeddedFiles(): array
        public function getEmbeddedProFiles(): array
        public function getEmbeddedMediaFiles(): array
        public function getEmbeddedSong(string $filename): ?Song  // lazy parse
        public function getDocument(): PlaylistDocument  // raw proto
    }
    ```
  - **REFACTOR**: Ensure lazy caching works correctly for Song objects

  **Must NOT do**:
  - Do NOT auto-parse .pro files in constructor — lazy parsing only in `getEmbeddedSong()`
  - Do NOT name this class `PlaylistDocument` — collision with proto
  - Do NOT store file paths — only in-memory bytes

  **Recommended Agent Profile**:
  - **Category**: `unspecified-high`
    - Reason: Top-level wrapper integrating nodes, entries, and embedded files
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 2 (with Tasks 4, 5)
  - **Blocks**: Tasks 7, 8, 9, 10
  - **Blocked By**: Tasks 1, 4, 5 (needs proto + both sub-wrappers)

  **References**:

  **Pattern References**:
  - `php/src/Song.php` — Top-level wrapper pattern: constructor takes proto object, builds internal indexes, provides convenience methods. PlaylistArchive is the Song equivalent for playlists
  - `php/src/ProFileReader.php:33-36` — Shows how Song is constructed from proto. PlaylistArchive constructor follows same pattern but also receives embedded files

  **API/Type References**:
  - `php/generated/Rv/Data/PlaylistDocument.php` — The proto class being wrapped. `getRootNode()` returns `Playlist`, `getType()` returns int, `getApplicationInfo()` returns `ApplicationInfo`
  - `php/src/PlaylistNode.php` — Wrapper from Task 5 — used for root node and child nodes
  - `php/src/PlaylistEntry.php` — Wrapper from Task 4 — used via PlaylistNode
  - `php/src/ProFileReader.php` — Used in `getEmbeddedSong()` to lazily parse .pro bytes. Important: ProFileReader reads from file path, so you need to use the `Presentation` proto directly: `(new Presentation())->mergeFromString($bytes)` then `new Song($presentation)`

  **WHY Each Reference Matters**:
  - `Song.php`: THIS is the class being mirrored. PlaylistArchive should feel identical in API style
  - `PlaylistDocument.php`: The exact proto API surface to delegate to
  - `ProFileReader.php:33-36`: Shows the proto->Song construction pattern. For lazy parsing, replicate this inline rather than going through file I/O

  **Acceptance Criteria**:
  - [x] `php/tests/PlaylistArchiveTest.php` exists with ≥10 test methods
  - [x] `php/src/PlaylistArchive.php` exists in namespace `ProPresenter\Parser`
  - [x] `cd php && php vendor/bin/phpunit --filter PlaylistArchiveTest` — ALL tests pass
  - [x] `getEmbeddedSong()` returns Song object via lazy parsing
  - [x] `getEmbeddedProFiles()` and `getEmbeddedMediaFiles()` correctly partition embedded files

  **QA Scenarios:**

  ```
  Scenario: Archive with embedded files partitions correctly
    Tool: Bash
    Preconditions: PlaylistArchive implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$doc = new Rv\Data\PlaylistDocument();
         \$root = new Rv\Data\Playlist();
         \$root->setName('PLAYLIST');
         \$doc->setRootNode(\$root);
         \$files = ['Song.pro' => 'prodata', 'Users/path/image.jpg' => 'imgdata'];
         \$archive = new ProPresenter\Parser\PlaylistArchive(\$doc, \$files);
         echo count(\$archive->getEmbeddedProFiles()) . ' ' . count(\$archive->getEmbeddedMediaFiles());
         "
      2. Assert output is '1 1'
    Expected Result: 1 .pro file and 1 media file correctly partitioned
    Failure Indicators: Wrong counts
    Evidence: .sisyphus/evidence/task-6-embedded-partition.txt
  ```

  **Commit**: YES (groups with T4, T5)
  - Message: `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers`
  - Files: `php/src/PlaylistArchive.php`, `php/tests/PlaylistArchiveTest.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit --filter PlaylistArchiveTest`

- [x] 7. ProPlaylistReader (TDD)

  **What to do**:
  - **RED**: Write `php/tests/ProPlaylistReaderTest.php` first:
    - `readThrowsOnMissingFile`: nonexistent path → InvalidArgumentException
    - `readThrowsOnEmptyFile`: 0-byte file → RuntimeException
    - `readThrowsOnInvalidZip`: non-ZIP content → RuntimeException
    - `readLoadsTestPlaylist`: reads `ref/TestPlaylist.proplaylist`, verifies name, entry count, item types
    - `readLoadsGottesdienst`: reads `ref/ExamplePlaylists/Gottesdienst.proplaylist`, verifies it has >20 entries
    - `readExtractsEmbeddedProFiles`: verifies `.pro` files are available via `getEmbeddedProFiles()`
    - `readExtractsEmbeddedMediaFiles`: verifies media files are available
    - `readParsesAllItemTypes`: verifies headers, presentations, placeholders are correctly typed
    - `readPreservesArrangementName`: reads a file with arrangement, verifies field 5 value
    - `readHandlesAllExamplePlaylists`: loop over all 4 test files, verify each loads without error
  - **GREEN**: Implement `php/src/ProPlaylistReader.php`:
    ```php
    namespace ProPresenter\Parser;
    use Rv\Data\PlaylistDocument;
    
    final class ProPlaylistReader {
        public static function read(string $filePath): PlaylistArchive {
            // 1. Validate file exists, not empty
            // 2. Read raw bytes
            // 3. Fix ZIP64 headers via Zip64Fixer::fix()
            // 4. Write fixed bytes to temp file
            // 5. Open with ZipArchive
            // 6. Extract 'data' entry -> deserialize as PlaylistDocument
            // 7. Extract all other entries as embedded files
            // 8. Close ZipArchive, delete temp file
            // 9. Return new PlaylistArchive($document, $embeddedFiles)
        }
    }
    ```
  - Handle the edge case where a .proplaylist might already have valid ZIP headers (no fix needed)
  - **REFACTOR**: Clean error messages, ensure temp file cleanup in all code paths (try/finally)

  **Must NOT do**:
  - Do NOT leave temp files behind on error — use try/finally
  - Do NOT decompress/recompress content — only read entries
  - Do NOT auto-parse .pro files — store as raw bytes (lazy parsing is in PlaylistArchive)

  **Recommended Agent Profile**:
  - **Category**: `deep`
    - Reason: Complex I/O with ZIP handling, temp files, error paths, and integration of Zip64Fixer + proto deserialization
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 3 (with Tasks 8, 9, 10)
  - **Blocks**: Tasks 8, 10, 11, 12
  - **Blocked By**: Tasks 2, 6 (needs Zip64Fixer + PlaylistArchive)

  **References**:

  **Pattern References**:
  - `php/src/ProFileReader.php` — EXACT pattern to follow: static `read(string $filePath)` method, same validation (file exists, not empty), same exception types. The difference is ProPlaylistReader reads a ZIP instead of raw protobuf
  - `php/tests/ProFileReaderTest.php` — Test structure: error cases first (missing file, empty file), then happy paths with real files, then diverse file loading

  **API/Type References**:
  - `php/src/Zip64Fixer.php` — From Task 2: `Zip64Fixer::fix(string $data): string` — call before opening ZIP
  - `php/src/PlaylistArchive.php` — From Task 6: constructor takes `(PlaylistDocument, array $embeddedFiles)` — this is what we return
  - `php/generated/Rv/Data/PlaylistDocument.php` — `mergeFromString()` to deserialize the `data` entry
  - PHP `ZipArchive` — `open()`, `getFromName()`, `numFiles`, `getNameIndex()`, `close()`

  **WHY Each Reference Matters**:
  - `ProFileReader.php`: The TEMPLATE. ProPlaylistReader should be structurally identical, just with ZIP extraction added
  - `Zip64Fixer.php`: MUST be called before ZipArchive::open() or it will fail with error 21
  - `PlaylistDocument::mergeFromString()`: The `data` entry is raw protobuf bytes — deserialize with this method

  **Test Data**:
  - `ref/TestPlaylist.proplaylist` — Small: 2 .pro files, 1 image, ~4 items
  - `ref/ExamplePlaylists/Gottesdienst.proplaylist` — Large: 14MB, 25+ items
  - `ref/ExamplePlaylists/Gottesdienst 2.proplaylist` — 10MB
  - `ref/ExamplePlaylists/Gottesdienst 3.proplaylist` — 16MB

  **Acceptance Criteria**:
  - [x] `php/tests/ProPlaylistReaderTest.php` exists with ≥10 test methods
  - [x] `php/src/ProPlaylistReader.php` exists with static `read()` method
  - [x] `cd php && php vendor/bin/phpunit --filter ProPlaylistReaderTest` — ALL tests pass
  - [x] All 4 .proplaylist test files load successfully
  - [x] Embedded .pro files and media files are accessible from returned PlaylistArchive
  - [x] No temp files left behind after read (success or failure)

  **QA Scenarios:**

  ```
  Scenario: Read TestPlaylist and inspect entries
    Tool: Bash
    Preconditions: ProPlaylistReader + all dependencies implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$archive = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
         echo 'Name: ' . \$archive->getName() . PHP_EOL;
         echo 'Entries: ' . \$archive->getEntryCount() . PHP_EOL;
         echo 'ProFiles: ' . count(\$archive->getEmbeddedProFiles()) . PHP_EOL;
         foreach (\$archive->getEntries() as \$e) { echo \$e->getType() . ': ' . \$e->getName() . PHP_EOL; }"
      2. Assert output contains 'Name: TestPlaylist'
      3. Assert output contains at least 2 entries
    Expected Result: Playlist loaded with correct name and typed entries
    Failure Indicators: Exception, empty entries, wrong name
    Evidence: .sisyphus/evidence/task-7-read-test-playlist.txt

  Scenario: Error on nonexistent file
    Tool: Bash
    Preconditions: ProPlaylistReader implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         try { ProPresenter\Parser\ProPlaylistReader::read('/nonexistent.proplaylist'); echo 'NO_EXCEPTION'; }
         catch (InvalidArgumentException \$e) { echo 'OK'; }"
      2. Assert output is 'OK'
    Expected Result: InvalidArgumentException thrown
    Failure Indicators: 'NO_EXCEPTION' or different exception type
    Evidence: .sisyphus/evidence/task-7-error-nonexistent.txt
  ```

  **Commit**: YES
  - Message: `feat(playlist): add ProPlaylistReader`
  - Files: `php/src/ProPlaylistReader.php`, `php/tests/ProPlaylistReaderTest.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistReaderTest`

- [x] 8. ProPlaylistWriter (TDD)

  **What to do**:
  - **RED**: Write `php/tests/ProPlaylistWriterTest.php` first:
    - `writeThrowsOnMissingDirectory`: target dir doesn't exist → InvalidArgumentException
    - `writeCreatesValidZipFile`: write a PlaylistArchive, verify output is valid ZIP
    - `writeContainsDataEntry`: output ZIP has `data` entry with valid protobuf
    - `writeContainsEmbeddedProFiles`: .pro files from archive are in ZIP at root level
    - `writeContainsEmbeddedMediaFiles`: media files are in ZIP at their original paths
    - `writeDeduplicatesFiles`: same filename referenced twice → stored once
    - `writeDataEntryDeserializesToSameDocument`: read back `data` entry, compare with original
  - **GREEN**: Implement `php/src/ProPlaylistWriter.php`:
    ```php
    namespace ProPresenter\Parser;
    
    final class ProPlaylistWriter {
        public static function write(PlaylistArchive $archive, string $filePath): void {
            // 1. Validate target directory exists
            // 2. Create temp ZipArchive
            // 3. Serialize PlaylistDocument to protobuf -> add as 'data' entry
            // 4. Add each embedded file (pro files at root, media at their paths)
            // 5. Close ZipArchive
            // 6. Move/copy temp file to target path
        }
    }
    ```
  - Use `ZipArchive::CM_STORE` for no compression (matching ProPresenter behavior)
  - Write clean, standard-compliant ZIP — do NOT reproduce ProPresenter's 98-byte quirk
  - **REFACTOR**: Ensure temp file cleanup, consistent error messages

  **Must NOT do**:
  - Do NOT reproduce ProPresenter's broken ZIP64 headers
  - Do NOT compress entries — use store method
  - Do NOT modify the PlaylistArchive or its Document during write

  **Recommended Agent Profile**:
  - **Category**: `deep`
    - Reason: ZIP creation with specific constraints, temp file management, and careful protobuf serialization
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 3 (with Tasks 7, 9, 10)
  - **Blocks**: Tasks 11, 12
  - **Blocked By**: Tasks 6, 7 (needs PlaylistArchive + Reader for verification)

  **References**:

  **Pattern References**:
  - `php/src/ProFileWriter.php` — EXACT pattern to follow: static `write(Song, string)` method, same validation, same exception types. The difference is writing ZIP instead of raw protobuf
  - `php/tests/ProFileWriterTest.php` — Test pattern for writer tests

  **API/Type References**:
  - `php/src/PlaylistArchive.php` — `getDocument()` returns `PlaylistDocument` for serialization, `getEmbeddedFiles()` returns `['filename' => bytes]` map
  - PHP `ZipArchive` — `open()` with `ZipArchive::CREATE`, `addFromString($name, $data)`, `setCompressionName($name, ZipArchive::CM_STORE)`, `close()`
  - `PlaylistDocument::serializeToString()` — Serializes proto to bytes for the `data` entry

  **WHY Each Reference Matters**:
  - `ProFileWriter.php`: Structural template — same validation pattern, same method signature style
  - `PlaylistArchive`: Source of ALL data to write — document for `data` entry, embedded files for other entries

  **Acceptance Criteria**:
  - [x] `php/tests/ProPlaylistWriterTest.php` exists with ≥7 test methods
  - [x] `php/src/ProPlaylistWriter.php` exists with static `write()` method
  - [x] `cd php && php vendor/bin/phpunit --filter ProPlaylistWriterTest` — ALL tests pass
  - [x] Written ZIP opens with standard `unzip -l` without errors
  - [x] Written ZIP uses store compression (no deflate)

  **QA Scenarios:**

  ```
  Scenario: Write and verify ZIP structure
    Tool: Bash
    Preconditions: Reader + Writer both implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$archive = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
         ProPresenter\Parser\ProPlaylistWriter::write(\$archive, '/tmp/test-write.proplaylist');
         echo file_exists('/tmp/test-write.proplaylist') ? 'EXISTS' : 'MISSING';"
      2. unzip -l /tmp/test-write.proplaylist 2>&1
      3. Assert ZIP listing shows 'data' entry and .pro files
      4. rm /tmp/test-write.proplaylist
    Expected Result: Valid ZIP with data + embedded files
    Failure Indicators: unzip errors or missing entries
    Evidence: .sisyphus/evidence/task-8-write-verify.txt
  ```

  **Commit**: YES
  - Message: `feat(playlist): add ProPlaylistWriter`
  - Files: `php/src/ProPlaylistWriter.php`, `php/tests/ProPlaylistWriterTest.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistWriterTest`

- [x] 9. ProPlaylistGenerator (TDD)

  **What to do**:
  - **RED**: Write `php/tests/ProPlaylistGeneratorTest.php` first:
    - `generateCreatesPlaylistArchive`: returns a PlaylistArchive object
    - `generateSetsPlaylistName`: verify archive name matches input
    - `generateCreatesHeaders`: header items with name and color
    - `generateCreatesPresentationItems`: presentation items with document path, arrangement UUID, arrangement name
    - `generateCreatesPlaceholders`: placeholder items
    - `generateSetsApplicationInfo`: application_info with platform and version
    - `generateSetsTypePresentation`: document type = TYPE_PRESENTATION
    - `generateAndWriteCreatesFile`: writes to disk and verifies readable
  - **GREEN**: Implement `php/src/ProPlaylistGenerator.php`:
    ```php
    namespace ProPresenter\Parser;
    
    final class ProPlaylistGenerator {
        public static function generate(
            string $name,
            array $items,  // [{type: 'header'|'presentation'|'placeholder', ...}]
            array $embeddedFiles = [],  // [filename => bytes]
        ): PlaylistArchive
        
        public static function generateAndWrite(
            string $filePath,
            string $name,
            array $items,
            array $embeddedFiles = [],
        ): PlaylistArchive
    }
    ```
  - Item array format:
    - Header: `['type' => 'header', 'name' => 'Section Name', 'color' => [r, g, b, a]]`
    - Presentation: `['type' => 'presentation', 'name' => 'Song Title', 'path' => 'file:///...', 'arrangement_uuid' => '...', 'arrangement_name' => 'normal']`
    - Placeholder: `['type' => 'placeholder', 'name' => 'Placeholder1']`
  - Build proper protobuf structure: PlaylistDocument → root Playlist ("PLAYLIST") → child Playlist (name) → PlaylistItems → PlaylistItem[]
  - Set application_info matching ProPresenter defaults (reuse `buildApplicationInfo()` pattern from `ProFileGenerator`)
  - Generate UUIDs for all objects
  - **REFACTOR**: Extract shared UUID/color/appinfo builders or reuse from ProFileGenerator

  **Must NOT do**:
  - Do NOT auto-embed .pro file content — user provides embedded files explicitly
  - Do NOT validate document_path URLs — accept whatever is provided
  - Do NOT add PlanningCenter or audio playlist support

  **Recommended Agent Profile**:
  - **Category**: `deep`
    - Reason: Complex protobuf construction with nested structure matching exact ProPresenter format
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 3 (with Tasks 7, 8, 10)
  - **Blocks**: Task 11
  - **Blocked By**: Task 6 (needs PlaylistArchive)

  **References**:

  **Pattern References**:
  - `php/src/ProFileGenerator.php` — THE template. Follow the exact same patterns: static `generate()` + `generateAndWrite()`, private `buildApplicationInfo()`, `newUuid()`, `uuidFromString()`, `colorFromArray()`. Consider reusing these methods or extracting to a trait/shared utility
  - `php/src/ProFileGenerator.php:55-60` — Method signature pattern: `generate(string $name, array $items, ...): Song`. PlaylistGenerator follows this but returns PlaylistArchive
  - `php/src/ProFileGenerator.php:137-149` — `buildApplicationInfo()` method — reuse or duplicate for playlist

  **API/Type References**:
  - `php/generated/Rv/Data/PlaylistDocument.php` — Top-level container: `setApplicationInfo()`, `setType()`, `setRootNode()`
  - `php/generated/Rv/Data/PlaylistDocument/Type.php` — `TYPE_PRESENTATION = 1`
  - `php/generated/Rv/Data/Playlist.php` — Node: `setUuid()`, `setName()`, `setPlaylists()`, `setItems()`
  - `php/generated/Rv/Data/Playlist/PlaylistArray.php` — `setPlaylists()` takes array of Playlist
  - `php/generated/Rv/Data/Playlist/PlaylistItems.php` — `setItems()` takes array of PlaylistItem
  - `php/generated/Rv/Data/PlaylistItem.php` — `setUuid()`, `setName()`, `setHeader()` / `setPresentation()` / `setPlaceholder()`
  - `php/generated/Rv/Data/PlaylistItem/Header.php` — `setColor()`
  - `php/generated/Rv/Data/PlaylistItem/Presentation.php` — `setDocumentPath()`, `setArrangement()`, `setArrangementName()`, `setUserMusicKey()`
  - `php/generated/Rv/Data/MusicKeyScale.php` — Default music key: `setMusicKey(MusicKeyScale\MusicKey::MUSIC_KEY_C)`

  **WHY Each Reference Matters**:
  - `ProFileGenerator.php`: The EXACT pattern to replicate. Same static factory, same UUID generation, same color building, same application info
  - All the proto class references: These are the API surface for constructing the nested protobuf tree. Getting any setter name wrong breaks generation

  **Acceptance Criteria**:
  - [x] `php/tests/ProPlaylistGeneratorTest.php` exists with ≥8 test methods
  - [x] `php/src/ProPlaylistGenerator.php` exists with `generate()` and `generateAndWrite()` methods
  - [x] `cd php && php vendor/bin/phpunit --filter ProPlaylistGeneratorTest` — ALL tests pass
  - [x] Generated playlist has correct structure: PlaylistDocument → root → child → items
  - [x] Header, presentation, and placeholder items all generate correctly

  **QA Scenarios:**

  ```
  Scenario: Generate a playlist with mixed items
    Tool: Bash
    Preconditions: ProPlaylistGenerator implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$archive = ProPresenter\Parser\ProPlaylistGenerator::generate('TestService', [
           ['type' => 'header', 'name' => 'Welcome', 'color' => [0.0, 0.5, 0.8, 1.0]],
           ['type' => 'presentation', 'name' => 'Amazing Grace', 'path' => 'file:///song.pro'],
           ['type' => 'placeholder', 'name' => 'Slot1'],
         ]);
         echo 'Name: ' . \$archive->getName() . PHP_EOL;
         echo 'Entries: ' . \$archive->getEntryCount() . PHP_EOL;
         foreach (\$archive->getEntries() as \$e) { echo \$e->getType() . ': ' . \$e->getName() . PHP_EOL; }"
      2. Assert output contains 'Name: TestService'
      3. Assert 3 entries with types header, presentation, placeholder
    Expected Result: All 3 item types created correctly
    Failure Indicators: Wrong count, wrong types, or exception
    Evidence: .sisyphus/evidence/task-9-generate-mixed.txt
  ```

  **Commit**: YES
  - Message: `feat(playlist): add ProPlaylistGenerator`
  - Files: `php/src/ProPlaylistGenerator.php`, `php/tests/ProPlaylistGeneratorTest.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit --filter ProPlaylistGeneratorTest`

- [x] 10. CLI tool: parse-playlist.php

  **What to do**:
  - Create `php/bin/parse-playlist.php` following the EXACT structure of `php/bin/parse-song.php`:
    - Shebang line: `#!/usr/bin/env php`
    - Require autoloader
    - Check argc, print usage
    - Read file with ProPlaylistReader::read()
    - Display:
      - Playlist name and UUID
      - Application info (platform, version)
      - Document type
      - Embedded file summary: N .pro files, N media files
      - All entries in order, with type-specific details:
        - Header: `[H] Section Name` with color
        - Presentation: `[P] Song Title (arrangement: normal)` with document path
        - Placeholder: `[-] Placeholder Name`
        - Cue: `[C] Cue Name`
      - Embedded .pro file list
      - Embedded media file list
  - Error handling: try/catch with user-friendly error messages

  **Must NOT do**:
  - Do NOT use colors/ANSI codes — keep plain text like parse-song.php
  - Do NOT auto-parse embedded .pro files — just list filenames

  **Recommended Agent Profile**:
  - **Category**: `quick`
    - Reason: Straightforward CLI script following an exact template
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 3 (with Tasks 7, 8, 9)
  - **Blocks**: Tasks 12, 13
  - **Blocked By**: Task 7 (needs ProPlaylistReader)

  **References**:

  **Pattern References**:
  - `php/bin/parse-song.php` — EXACT template. Copy structure line by line: shebang, autoload, argc check, try/catch, output formatting. Replace Song-specific output with Playlist-specific output

  **API/Type References**:
  - `php/src/ProPlaylistReader.php` — `ProPlaylistReader::read($filePath)` returns `PlaylistArchive`
  - `php/src/PlaylistArchive.php` — `getName()`, `getEntries()`, `getEmbeddedProFiles()`, `getEmbeddedMediaFiles()`
  - `php/src/PlaylistEntry.php` — `getType()`, `getName()`, `getArrangementName()`, `getDocumentPath()`, `getHeaderColor()`

  **WHY Each Reference Matters**:
  - `parse-song.php`: Byte-for-byte template. The playlist CLI should be stylistically identical

  **Acceptance Criteria**:
  - [x] `php/bin/parse-playlist.php` exists with shebang line
  - [x] `php php/bin/parse-playlist.php` (no args) → usage message + exit code 1
  - [x] `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` → structured output
  - [x] `php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist` → output without errors
  - [x] Output shows entries with type indicators ([H], [P], [-], [C])

  **QA Scenarios:**

  ```
  Scenario: CLI with TestPlaylist
    Tool: Bash
    Preconditions: parse-playlist.php and all dependencies implemented
    Steps:
      1. php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist
      2. Assert exit code 0
      3. Assert output contains 'Playlist:' or playlist name
      4. Assert output contains at least one '[P]' or '[H]' entry
    Expected Result: Clean structured output with entry type indicators
    Failure Indicators: Non-zero exit, exception trace in output
    Evidence: .sisyphus/evidence/task-10-cli-test-playlist.txt

  Scenario: CLI with no arguments shows usage
    Tool: Bash
    Preconditions: parse-playlist.php exists
    Steps:
      1. php php/bin/parse-playlist.php 2>&1; echo "EXIT:$?"
      2. Assert output contains 'Usage:'
      3. Assert exit code is 1
    Expected Result: Usage message and exit code 1
    Failure Indicators: No usage message or exit code 0
    Evidence: .sisyphus/evidence/task-10-cli-usage.txt
  ```

  **Commit**: YES
  - Message: `feat(cli): add parse-playlist.php CLI tool`
  - Files: `php/bin/parse-playlist.php`
  - Pre-commit: `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist`

- [x] 11. Round-trip integration tests

  **What to do**:
  - Add integration tests to `php/tests/ProPlaylistWriterTest.php` (or a new `php/tests/ProPlaylistIntegrationTest.php`):
    - `roundTripPreservesPlaylistName`: read → write → read, compare names
    - `roundTripPreservesEntryCount`: same number of entries after round-trip
    - `roundTripPreservesEntryTypes`: all item types preserved (header/presentation/placeholder)
    - `roundTripPreservesArrangementNames`: field 5 values survive round-trip
    - `roundTripPreservesEmbeddedFileCount`: same number of embedded files
    - `roundTripPreservesDocumentPaths`: presentation document_path URLs unchanged
    - `roundTripPreservesHeaderColors`: header color RGBA values unchanged
    - `generatedPlaylistReadableByReader`: generate → write → read back
  - Use `ref/TestPlaylist.proplaylist` as primary test file (small, fast)
  - Write to temp file, read back, compare all fields, clean up temp file

  **Must NOT do**:
  - Do NOT compare raw bytes (proto serialization may reorder fields) — compare logical values
  - Do NOT modify the original test files

  **Recommended Agent Profile**:
  - **Category**: `deep`
    - Reason: Integration testing requiring coordination of reader + writer + generator with thorough field-level comparison
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 4 (with Tasks 12, 13)
  - **Blocks**: Nothing
  - **Blocked By**: Tasks 7, 8, 9 (needs Reader + Writer + Generator)

  **References**:

  **Pattern References**:
  - `php/tests/BinaryFidelityTest.php` — If exists, shows existing round-trip or fidelity test patterns
  - `php/tests/ProFileWriterTest.php` — Shows how writer tests are structured

  **API/Type References**:
  - `php/src/ProPlaylistReader.php` — `read(string): PlaylistArchive`
  - `php/src/ProPlaylistWriter.php` — `write(PlaylistArchive, string): void`
  - `php/src/ProPlaylistGenerator.php` — `generate(string, array): PlaylistArchive`
  - `php/src/PlaylistArchive.php` — All getter methods for comparison

  **WHY Each Reference Matters**:
  - Reader + Writer + Generator: All three are exercised together. The integration tests prove they interoperate correctly
  - BinaryFidelityTest: If it exists, it shows the project's existing approach to verifying round-trip integrity

  **Acceptance Criteria**:
  - [x] Integration test file exists with ≥8 test methods
  - [x] `cd php && php vendor/bin/phpunit --filter Integration` (or --filter ProPlaylistWriter) — ALL pass
  - [x] Round-trip of TestPlaylist.proplaylist preserves all fields
  - [x] Generated → written → read back works correctly

  **QA Scenarios:**

  ```
  Scenario: Full round-trip of TestPlaylist
    Tool: Bash
    Preconditions: Reader + Writer implemented
    Steps:
      1. php -r "
         require 'php/vendor/autoload.php';
         \$a = ProPresenter\Parser\ProPlaylistReader::read('ref/TestPlaylist.proplaylist');
         ProPresenter\Parser\ProPlaylistWriter::write(\$a, '/tmp/rt-test.proplaylist');
         \$b = ProPresenter\Parser\ProPlaylistReader::read('/tmp/rt-test.proplaylist');
         echo (\$a->getName() === \$b->getName() ? 'NAME_OK' : 'NAME_FAIL') . ' ';
         echo (\$a->getEntryCount() === \$b->getEntryCount() ? 'COUNT_OK' : 'COUNT_FAIL');
         unlink('/tmp/rt-test.proplaylist');"
      2. Assert output is 'NAME_OK COUNT_OK'
    Expected Result: Name and entry count preserved through round-trip
    Failure Indicators: Any _FAIL in output
    Evidence: .sisyphus/evidence/task-11-roundtrip.txt
  ```

  **Commit**: YES (groups with T12)
  - Message: `test(playlist): add integration and real-world validation tests`
  - Files: `php/tests/ProPlaylistIntegrationTest.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit`

- [x] 12. Large-file / real-world validation tests

  **What to do**:
  - Add validation tests (in `php/tests/ProPlaylistIntegrationTest.php` or separate file):
    - `readsAllExamplePlaylistsWithoutError`: loop over all 4 .proplaylist files, read each, assert no exceptions
    - `gottesdienstHasExpectedStructure`: the Gottesdienst playlists should have >20 entries, mix of headers and presentations
    - `allPresentationItemsHaveDocumentPath`: every presentation entry has a non-empty document path
    - `embeddedProFilesExistForPresentations`: for each presentation entry, verify corresponding .pro is in embedded files
    - `cliOutputMatchesReaderData`: run parse-playlist.php, verify output entry count matches reader entry count
  - Test all files in `ref/ExamplePlaylists/` plus `ref/TestPlaylist.proplaylist`

  **Must NOT do**:
  - Do NOT modify the test data files
  - Do NOT hardcode exact entry counts (might vary) — use minimum thresholds

  **Recommended Agent Profile**:
  - **Category**: `unspecified-high`
    - Reason: Validation tests against real-world data files
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 4 (with Tasks 11, 13)
  - **Blocks**: Nothing
  - **Blocked By**: Tasks 7, 10 (needs Reader + CLI)

  **References**:

  **Pattern References**:
  - `php/tests/MassValidationTest.php` — If exists, shows pattern for testing against many files
  - `php/tests/ProFileReaderTest.php:52-78` — `readLoadsDiverseReferenceFilesSuccessfully` — this exact test pattern: loop over real files, verify each loads

  **API/Type References**:
  - `php/src/ProPlaylistReader.php` — `read()` to load each test file
  - `php/src/PlaylistArchive.php` — Getters for validation assertions

  **Test Data**:
  - `ref/TestPlaylist.proplaylist`
  - `ref/ExamplePlaylists/Gottesdienst.proplaylist`
  - `ref/ExamplePlaylists/Gottesdienst 2.proplaylist`
  - `ref/ExamplePlaylists/Gottesdienst 3.proplaylist`

  **Acceptance Criteria**:
  - [x] Validation tests exist with ≥5 test methods
  - [x] `cd php && php vendor/bin/phpunit` — ALL tests pass including validation
  - [x] All 4 test files load and validate without errors

  **QA Scenarios:**

  ```
  Scenario: All example playlists load
    Tool: Bash
    Preconditions: Reader implemented
    Steps:
      1. for f in ref/TestPlaylist.proplaylist ref/ExamplePlaylists/*.proplaylist; do
           php -r "require 'php/vendor/autoload.php'; \$a = ProPresenter\Parser\ProPlaylistReader::read('$f'); echo basename('$f') . ': ' . \$a->getEntryCount() . ' entries' . PHP_EOL;"
         done
      2. Assert all 4 files produce output with entry counts > 0
    Expected Result: All files load with non-zero entry counts
    Failure Indicators: Any exception or 0 entries
    Evidence: .sisyphus/evidence/task-12-all-files.txt
  ```

  **Commit**: YES (groups with T11)
  - Message: `test(playlist): add integration and real-world validation tests`
  - Files: `php/tests/ProPlaylist*Test.php`
  - Pre-commit: `cd php && php vendor/bin/phpunit`

- [x] 13. AGENTS.md documentation update

  **What to do**:
  - Update `AGENTS.md` to document the new .proplaylist module, following the exact style of the existing `.pro` module documentation:
    - Add a new section for `.proplaylist` files
    - Document the PHP module usage:
      - Reading: `ProPlaylistReader::read('path/to/file.proplaylist')` → `PlaylistArchive`
      - Accessing structure: archive → entries → type-specific data
      - Writing: `ProPlaylistWriter::write($archive, 'output.proplaylist')`
      - Generating: `ProPlaylistGenerator::generate(name, items, embeddedFiles)`
      - Lazy Song parsing: `$archive->getEmbeddedSong('filename.pro')`
    - Document CLI tool: `php php/bin/parse-playlist.php path/to/file.proplaylist`
    - Link to format specification: `spec/pp_playlist_spec.md`
    - List key files (all new PHP source files)

  **Must NOT do**:
  - Do NOT modify existing .pro module documentation
  - Do NOT remove or change any existing content in AGENTS.md
  - Do NOT add excessive detail — match the conciseness of existing sections

  **Recommended Agent Profile**:
  - **Category**: `quick`
    - Reason: Documentation addition following an exact template
  - **Skills**: []

  **Parallelization**:
  - **Can Run In Parallel**: YES
  - **Parallel Group**: Wave 4 (with Tasks 11, 12)
  - **Blocks**: Nothing
  - **Blocked By**: Tasks 7, 10 (needs Reader + CLI to be final)

  **References**:

  **Pattern References**:
  - `AGENTS.md` — The EXISTING documentation structure. The playlist section must be added in the same style, same heading levels, same code block formatting as the existing "PHP Module Usage" section

  **Acceptance Criteria**:
  - [x] `AGENTS.md` contains a new section for `.proplaylist` files
  - [x] Section includes reading, writing, generating, CLI usage
  - [x] Section lists all new key files
  - [x] Existing content unchanged

  **QA Scenarios:**

  ```
  Scenario: AGENTS.md has playlist documentation
    Tool: Bash (grep)
    Preconditions: Task 13 complete
    Steps:
      1. grep -c 'proplaylist\|ProPlaylistReader\|PlaylistArchive\|parse-playlist' AGENTS.md
      2. Assert count >= 4
    Expected Result: All key terms present in documentation
    Failure Indicators: Key terms missing
    Evidence: .sisyphus/evidence/task-13-agents-md.txt
  ```

  **Commit**: YES
  - Message: `docs(agents): update AGENTS.md with playlist module documentation`
  - Files: `AGENTS.md`
  - Pre-commit: —

## Final Verification Wave

> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.

- [x] F1. **Plan Compliance Audit** — `oracle`
  Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
  Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`

- [x] F2. **Code Quality Review** — `unspecified-high`
  Run `php vendor/bin/phpunit` from `php/` directory. Review all new files for: hardcoded paths, empty catches, `@` error suppression, unused imports, inconsistent naming. Check AI slop: excessive comments, over-abstraction, generic variable names. Verify PSR-4 autoloading works for all new classes.
  Output: `Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT`

- [x] F3. **Real Manual QA** — `unspecified-high`
  Start from clean state. Run `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist`. Run with all 3 Gottesdienst playlists. Verify output is human-readable and complete. Test error cases: nonexistent file, non-ZIP file, empty file. Run round-trip test: read → write → read and compare.
  Output: `CLI [N/N pass] | Error handling [N/N] | Round-trip [PASS/FAIL] | VERDICT`

- [x] F4. **Scope Fidelity Check** — `deep`
  For each task: read "What to do", read actual diff. Verify 1:1 — everything in spec was built, nothing beyond spec was built. Check "Must NOT do" compliance. Detect cross-task contamination. Flag unaccounted changes.
  Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`

---

## Commit Strategy

| Group | Message | Files | Pre-commit |
|-------|---------|-------|------------|
| T1 | `feat(proto): add arrangement_name field 5 to PlaylistItem.Presentation` | `php/proto/playlist.proto`, `php/generated/**` | — |
| T2 | `feat(playlist): add Zip64Fixer for ProPresenter ZIP archives` | `php/src/Zip64Fixer.php`, `php/tests/Zip64FixerTest.php` | `php vendor/bin/phpunit --filter Zip64FixerTest` |
| T3 | `docs(spec): add .proplaylist format specification` | `spec/pp_playlist_spec.md` | — |
| T4+T5+T6 | `feat(playlist): add PlaylistEntry, PlaylistNode, PlaylistArchive wrappers` | `php/src/Playlist*.php`, `php/tests/Playlist*Test.php` | `php vendor/bin/phpunit --filter 'PlaylistEntry\|PlaylistNode\|PlaylistArchive'` |
| T7 | `feat(playlist): add ProPlaylistReader` | `php/src/ProPlaylistReader.php`, `php/tests/ProPlaylistReaderTest.php` | `php vendor/bin/phpunit --filter ProPlaylistReaderTest` |
| T8 | `feat(playlist): add ProPlaylistWriter` | `php/src/ProPlaylistWriter.php`, `php/tests/ProPlaylistWriterTest.php` | `php vendor/bin/phpunit --filter ProPlaylistWriterTest` |
| T9 | `feat(playlist): add ProPlaylistGenerator` | `php/src/ProPlaylistGenerator.php`, `php/tests/ProPlaylistGeneratorTest.php` | `php vendor/bin/phpunit --filter ProPlaylistGeneratorTest` |
| T10 | `feat(cli): add parse-playlist.php CLI tool` | `php/bin/parse-playlist.php` | `php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist` |
| T11+T12 | `test(playlist): add integration and real-world validation tests` | `php/tests/ProPlaylist*Test.php` | `php vendor/bin/phpunit` |
| T13 | `docs(agents): update AGENTS.md with playlist module documentation` | `AGENTS.md` | — |

---

## Success Criteria

### Verification Commands
```bash
# All tests pass
cd php && php vendor/bin/phpunit  # Expected: OK (N tests, N assertions)

# CLI tool works with test file
php php/bin/parse-playlist.php ref/TestPlaylist.proplaylist  # Expected: structured output showing items

# CLI tool works with large real-world file
php php/bin/parse-playlist.php ref/ExamplePlaylists/Gottesdienst.proplaylist  # Expected: structured output, no errors

# Reader handles all example files
for f in ref/ExamplePlaylists/*.proplaylist; do php php/bin/parse-playlist.php "$f"; done  # Expected: all succeed
```

### Final Checklist
- [x] All "Must Have" present
- [x] All "Must NOT Have" absent
- [x] All PHPUnit tests pass
- [x] CLI tool handles all 4 test files
- [x] Round-trip fidelity verified
- [x] Format spec document complete
- [x] AGENTS.md updated
