# Auto Post AI — Ide ke Video Auto Upload (Shared Hosting) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Bangun sistem PHP di shared hosting di mana user input 1 ide → otomatis generate plan → naskah (jika generatif) → storyboard JSON → render video (slideshow murah atau generatif mahal) → jadwal upload → auto-publish ke YouTube/TikTok/IG/FB sesuai jadwal tanpa intervensi manual.

**Architecture:** Orkestrator PHP murni sebagai queue+scheduler (polling MySQL via cron tiap 5 menit, 1 job per tick). Semua AI/render via API eksternal (OpenAI, ElevenLabs, Pexels, Runway/Veo atau Shotstack/Creatomate fallback). Provider di-abstract via interface sehingga ganti API tanpa ubah orchestrator. FFmpeg auto-detect, jika tidak ada pakai mode api_only.

**Tech Stack:** PHP 8.2+, MySQL 5.7+/8.0, Cron cPanel, OpenAI API (GPT-4o-mini), ElevenLabs/OpenAI TTS, Pexels API, Shotstack/Creatomate atau Runway API (generatif), YouTube Data API v3, TikTok Upload API, Instagram Graph API.

**Spec:** `docs/superpowers/specs/2026-09-06-auto-post-ai-design.md`

## Global Constraints

- MUST run on shared hosting cPanel PHP + MySQL + Cron — no Redis, no Supervisor, no GPU, no daemon, no Node/Python required
- PHP >= 8.2, MySQL JSON type supported
- Max execution per cron tick < 25s, max 1 concurrent job (polling DB, not Redis queue)
- Cron protected by `?token=SECRET` 32-char random in `.env`
- API keys & OAuth tokens encrypted at rest (AES-256), never committed
- Storyboard JSON must be strictly validated (total duration == target 30/60s)
- Costs: slideshow $0.02-0.10/video, generative $0.30-1.00/video — dry-run mode required to save cost

---

## File Structure

```
/
├── docs/superpowers/specs/2026-09-06-auto-post-ai-design.md
├── docs/superpowers/plans/2026-09-06-auto-post-ai.md
├── public/
│   ├── index.php          # front controller
│   ├── cron.php           # Pipeline tick (PipelineService::tick)
│   ├── publish.php        # Publish tick (PublishService::tick)
│   ├── health.php         # DB + token expiry check
│   └── assets/
├── app/
│   ├── Services/
│   │   ├── PipelineService.php
│   │   ├── PublishService.php
│   │   ├── LLMService.php
│   │   ├── TTSService.php
│   │   ├── RenderRouter.php
│   │   └── UploadService.php
│   ├── Providers/
│   │   ├── LLMProviderInterface.php
│   │   ├── OpenAIProvider.php
│   │   ├── ClaudeProvider.php
│   │   ├── RenderProviderInterface.php
│   │   ├── ShotstackProvider.php
│   │   ├── RunwayProvider.php
│   │   ├── TTSProviderInterface.php
│   │   └── ElevenLabsProvider.php
│   │   └── UploadProviderInterface.php
│   │       ├── YoutubeProvider.php
│   │       ├── TiktokProvider.php
│   │       └── InstagramProvider.php
│   ├── Models/
│   │   ├── Project.php
│   │   ├── Job.php
│   │   └── Video.php
│   └── Validators/
│       └── StoryboardValidator.php
├── prompts/
│   ├── plan.v1.txt
│   ├── naskah.v1.txt
│   └── storyboard.v1.txt
├── config/
│   ├── app.php
│   └── providers.php
├── migrations/
│   ├── 001_create_projects.sql
│   ├── 002_create_jobs.sql
│   ├── 003_create_storyboards_videos_schedules.sql
│   └── 004_create_tokens_logs_settings.sql
├── .env.example
└── tests/
    ├── PipelineServiceTest.php
    ├── LLMServiceTest.php
    ├── StoryboardValidatorTest.php
    ├── RenderRouterTest.php
    └── PublishServiceTest.php
```

---

### Task 1: Scaffolding + DB Migrations + Env + Cron Entrypoints

**Files:**
- Create: `public/index.php`, `public/cron.php`, `public/publish.php`, `public/health.php`
- Create: `migrations/001_create_projects.sql` … `004_create_tokens_logs_settings.sql`
- Create: `.env.example`, `config/app.php`
- Test: `tests/PipelineServiceTest.php` (initial)

**Interfaces:**
- Consumes: none
- Produces: `PipelineService::tick(): int` returns processed count, DB schema ready, cron token guard

- [ ] **Step 1: Write the failing test**

```php
// tests/PipelineServiceTest.php
use PHPUnit\Framework\TestCase;
class PipelineServiceTest extends TestCase {
    public function test_tick_returns_zero_when_no_pending_jobs() {
        $svc = new PipelineService(new FakePDO([]));
        $this->assertEquals(0, $svc->tick());
    }
    public function test_cron_token_guard_rejects_invalid() {
        $_GET['token'] = 'bad';
        $this->expectException(Exception::class);
        require 'public/cron.php';
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/PipelineServiceTest.php -v`
Expected: FAIL class not found

- [ ] **Step 3: Write minimal implementation**

```php
// app/Services/PipelineService.php
class PipelineService {
  public function __construct(private PDO $pdo) {}
  public function tick(): int {
    $row = $this->pdo->query("SELECT * FROM jobs WHERE status='pending' ORDER BY created_at LIMIT 1")->fetch();
    if (!$row) return 0;
    // claim + process one job (stub)
    return 1;
  }
}
// public/cron.php
require '../config/app.php';
if (($_GET['token'] ?? '') !== getenv('CRON_TOKEN')) { http_response_code(403); exit('forbidden'); }
$svc = new PipelineService($pdo);
echo $svc->tick();
// .env.example
CRON_TOKEN=changeme_32_char_random
RENDER_MODE=api_only
OPENAI_API_KEY=
```
Create migrations per spec section 4 (projects, jobs, storyboards/videos/schedules, platform_tokens/job_logs/settings). Each sql creates table with indexes on status, publish_at.

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/PipelineServiceTest.php -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Services/PipelineService.php public/cron.php public/publish.php public/health.php migrations/ .env.example tests/PipelineServiceTest.php
git commit -m "feat: scaffold shared-hosting pipeline with DB migrations and cron guard"
```

---

### Task 2: Pipeline Orchestrator — State Machine Ide → Jobs Chain

**Files:**
- Modify: `app/Services/PipelineService.php`
- Create: `app/Models/Project.php`, `app/Models/Job.php`
- Test: `tests/PipelineServiceTest.php` (extend)

**Interfaces:**
- Consumes: `LLMService::generate(string $type, array $ctx): array`, `RenderRouter::render(array $storyboard): string`
- Produces: `PipelineService::createProject(string $idea, bool $isGenerative, array $platforms, string $publishAt): int`, `PipelineService::tick(): int`, `Job::claimNext(PDO): ?array`

- [ ] **Step 1: Write the failing test**

```php
public function test_create_project_chains_plan_job() {
  $svc = new PipelineService($this->pdo);
  $id = $svc->createProject('ide: tips masak hemat', true, ['youtube'], '2026-09-07 19:00:00');
  $this->assertGreaterThan(0, $id);
  $jobs = $this->pdo->query("SELECT type FROM jobs WHERE project_id=$id")->fetchAll();
  $this->assertEquals('plan', $jobs[0]['type']);
}
public function test_tick_advances_plan_to_naskah_when_generative() {
  // seed project + plan job done
  $svc->tick(); // should create naskah job
  $this->assertEquals('naskah', $this->pdo->query("SELECT type FROM jobs WHERE status='pending'")->fetch()['type']);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/PipelineServiceTest.php::test_create_project_chains_plan_job -v`
Expected: FAIL method not exists

- [ ] **Step 3: Write minimal implementation**

```php
class PipelineService {
  public function createProject(string $idea, bool $isGen, array $platforms, string $publishAt): int {
    $this->pdo->prepare("INSERT INTO projects (title, idea_raw, is_generative, status) VALUES (?,?,?,?)")->execute([$idea,$idea,$isGen?1:0,'pending']);
    $pid = $this->pdo->lastInsertId();
    $this->pdo->prepare("INSERT INTO jobs (project_id,type,status,payload) VALUES (?,?,?,?)")->execute([$pid,'plan','pending', json_encode(['idea'=>$idea])]);
    return $pid;
  }
  public function tick(): int {
    $job = $this->claimNext();
    if (!$job) return 0;
    $this->pdo->prepare("UPDATE jobs SET status='running' WHERE id=?")->execute([$job['id']]);
    try {
      $result = $this->dispatch($job);
      $this->pdo->prepare("UPDATE jobs SET status='done', result=? WHERE id=?")->execute([json_encode($result),$job['id']]);
      $this->chainNext($job, $result);
      $this->pdo->prepare("INSERT INTO job_logs (job_id,level,message) VALUES (?,?,?)")->execute([$job['id'],'info','done']);
    } catch (Exception $e) {
      $attempts = $job['attempts']+1;
      $next = date('Y-m-d H:i:s', strtotime(['+5 minutes','+30 minutes','+2 hours'][$attempts-1] ?? '+2 hours'));
      $status = $attempts>=3 ? 'failed':'pending';
      $this->pdo->prepare("UPDATE jobs SET status=?, attempts=?, next_retry_at=? WHERE id=?")->execute([$status,$attempts,$next,$job['id']]);
    }
    return 1;
  }
}
```

Chain: `plan→(if generative) naskah→storyboard→render→schedule`. Non-generative skips naskah.

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/PipelineServiceTest.php -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Services/PipelineService.php app/Models/Project.php app/Models/Job.php tests/PipelineServiceTest.php
git commit -m "feat: pipeline state machine with job chaining and retry backoff"
```

---

### Task 3: LLM Service — Plan / Naskah / Storyboard via OpenAI + Fallback

**Files:**
- Create: `app/Providers/LLMProviderInterface.php`, `app/Providers/OpenAIProvider.php`, `app/Providers/ClaudeProvider.php`
- Create: `app/Services/LLMService.php`
- Create: `prompts/plan.v1.txt`, `prompts/naskah.v1.txt`, `prompts/storyboard.v1.txt`
- Test: `tests/LLMServiceTest.php`

**Interfaces:**
- Consumes: `LLMProviderInterface::chat(string $prompt): string`
- Produces: `LLMService::generatePlan(string $idea): array`, `generateNaskah(array $plan): array`, `generateStoryboard(array $naskah): array`

- [ ] **Step 1: Write the failing test**

```php
public function test_generate_plan_returns_structure() {
  $svc = new LLMService(new FakeLLM(['hook'=>'...','angle'=>'...']));
  $res = $svc->generatePlan('ide: investasi pemula');
  $this->assertArrayHasKey('hook', $res);
}
public function test_fallback_on_timeout() {
  $svc = new LLMService(new FailingProvider(), new FakeLLM(['hook'=>'fallback']));
  $res = $svc->generatePlan('ide x');
  $this->assertEquals('fallback', $res['hook']);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/LLMServiceTest.php -v`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

```php
interface LLMProviderInterface { public function chat(string $prompt, float $timeout=15): string; }
class OpenAIProvider implements LLMProviderInterface {
  public function chat(string $prompt, float $timeout=15): string {
    $ch = curl_init('https://api.openai.com/v1/chat/completions');
    curl_setopt_array($ch, [CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>json_encode(['model'=>'gpt-4o-mini','messages'=>[['role'=>'user','content'=>$prompt]]]), CURLOPT_HTTPHEADER=>['Authorization: Bearer '.getenv('OPENAI_API_KEY'),'Content-Type: application/json'], CURLOPT_TIMEOUT=>$timeout, CURLOPT_RETURNTRANSFER=>true]);
    $res = curl_exec($ch); if (curl_errno($ch)) throw new Exception(curl_error($ch)); return $res;
  }
}
class LLMService {
  public function __construct(private LLMProviderInterface $primary, private ?LLMProviderInterface $fallback=null) {}
  public function generatePlan(string $idea): array { $p = str_replace('{{idea}}',$idea,file_get_contents('prompts/plan.v1.txt')); $json=$this->callWithFallback($p); return json_decode($json,true); }
  private function callWithFallback(string $prompt): string { try { return $this->primary->chat($prompt);} catch(Exception $e){ if($this->fallback) return $this->fallback->chat($prompt); throw $e; } }
}
```

Prompts versioned plain text with `{{idea}}`, `{{plan}}` placeholders, strict JSON output instruction.

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/LLMServiceTest.php -v`
Expected: PASS (with fakes)

- [ ] **Step 5: Commit**

```bash
git add app/Services/LLMService.php app/Providers/ prompts/ tests/LLMServiceTest.php
git commit -m "feat: LLM service with prompt versioning and fallback"
```

---

### Task 4: Storyboard Validator (Strict JSON)

**Files:**
- Create: `app/Validators/StoryboardValidator.php`
- Test: `tests/StoryboardValidatorTest.php`

**Interfaces:**
- Consumes: `array $json`
- Produces: `StoryboardValidator::validate(array $sb, int $targetDuration=60): bool` throws ValidationException, `totalDuration(array): int`

- [ ] **Step 1: Write the failing test**

```php
public function test_valid_storyboard_passes() {
  $v = new StoryboardValidator();
  $this->assertTrue($v->validate(['scenes'=>[['id'=>1,'duration'=>10,'voice_over'=>'hai','visual_prompt'=>'kitchen','transition'=>'cut','caption'=>'Hai']]], 10));
}
public function test_invalid_duration_fails() {
  $v = new StoryboardValidator();
  $this->expectException(ValidationException::class);
  $v->validate(['scenes'=>[['id'=>1,'duration'=>5,'voice_over'=>'x','visual_prompt'=>'y','transition'=>'cut','caption'=>'z']]], 10);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/StoryboardValidatorTest.php -v`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

```php
class StoryboardValidator {
  public function validate(array $sb, int $target=60): bool {
    if (!isset($sb['scenes']) || !is_array($sb['scenes'])) throw new ValidationException('scenes required');
    $sum = array_sum(array_column($sb['scenes'],'duration'));
    if ($sum !== $target) throw new ValidationException("duration sum $sum != $target");
    foreach($sb['scenes'] as $s){ foreach(['voice_over','visual_prompt','caption'] as $k) if(empty($s[$k])) throw new ValidationException("$k required"); }
    return true;
  }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/StoryboardValidatorTest.php -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Validators/StoryboardValidator.php tests/StoryboardValidatorTest.php
git commit -m "feat: strict storyboard validator with duration check"
```

---

### Task 5: Render Router — TTS + Pexels + Shotstack/Creatomate atau Runway (Generative)

**Files:**
- Create: `app/Services/TTSService.php`, `app/Providers/ElevenLabsProvider.php`
- Create: `app/Services/RenderRouter.php`, `app/Providers/ShotstackProvider.php`, `app/Providers/RunwayProvider.php`
- Test: `tests/RenderRouterTest.php`

**Interfaces:**
- Consumes: `array $storyboard`, `bool $isGenerative`
- Produces: `RenderRouter::render(int $projectId, array $sb, bool $isGen): string` returns external_id, `poll(string $externalId): string` status, FFmpeg auto-detect

- [ ] **Step 1: Write the failing test**

```php
public function test_router_picks_shotstack_for_slideshow() {
  $router = new RenderRouter(new FakeShotstack(), new FakeRunway(), 'api_only');
  $id = $router->render(1, ['scenes'=>[/*...*/]], false);
  $this->assertStringStartsWith('shotstack_', $id);
}
public function test_router_picks_runway_for_generative() {
  $router = new RenderRouter(new FakeShotstack(), new FakeRunway(), 'api_only');
  $id = $router->render(1, ['scenes'=>[]], true);
  $this->assertStringStartsWith('runway_', $id);
}
public function test_ffmpeg_detect_sets_mode() {
  $mode = RenderRouter::detectFFmpeg(); // exec check
  $this->assertContains($mode, ['local','api_only']);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/RenderRouterTest.php -v`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

```php
class RenderRouter {
  public static function detectFFmpeg(): string { exec('ffmpeg -version 2>&1', $o, $c); return $c===0?'local':'api_only'; }
  public function __construct(private RenderProviderInterface $shotstack, private RenderProviderInterface $runway, private string $mode) {}
  public function render(int $pid, array $sb, bool $isGen): string {
    if ($isGen) return $this->runway->create($sb);
    // slideshow: fetch Pexels images per visual_prompt, call TTS per voice_over, then shotstack
    if ($this->mode==='local') { /* build ffmpeg concat locally - stub */ }
    return $this->shotstack->create($sb);
  }
}
class ShotstackProvider implements RenderProviderInterface {
  public function create(array $sb): string { /* POST https://api.shotstack.io/v1/render */ return 'shotstack_'.uniqid(); }
}
```

TTS stub: `TTSService::synthesize(string $text): string` returns audio URL/file.

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/RenderRouterTest.php -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Services/RenderRouter.php app/Services/TTSService.php app/Providers/ tests/RenderRouterTest.php
git commit -m "feat: render router with FFmpeg detect and generative/slideshow branching"
```

---

### Task 6: Scheduler + Publish Cron (Jadwal Tayang)

**Files:**
- Create: `app/Services/PublishService.php`
- Modify: `public/publish.php`
- Test: `tests/PublishServiceTest.php`

**Interfaces:**
- Consumes: `UploadService::upload(string $platform, string $videoUrl, array $meta): string`
- Produces: `PublishService::schedule(int $videoId, array $platforms, string $publishAt): void`, `PublishService::tick(): int`

- [ ] **Step 1: Write the failing test**

```php
public function test_schedule_creates_rows_per_platform() {
  $svc = new PublishService($this->pdo, new FakeUploader());
  $svc->schedule(1, ['youtube','tiktok'], '2026-09-07 19:00:00');
  $count = $this->pdo->query("SELECT COUNT(*) c FROM schedules WHERE video_id=1")->fetch()['c'];
  $this->assertEquals(2, $count);
}
public function test_tick_publishes_due_videos() {
  // insert schedule publish_at = now -1 min
  $svc = new PublishService($this->pdo, new FakeUploader());
  $n = $svc->tick();
  $this->assertEquals(1, $n);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/PublishServiceTest.php -v`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

```php
class PublishService {
  public function schedule(int $vid, array $plats, string $at): void {
    foreach($plats as $p) $this->pdo->prepare("INSERT INTO schedules (video_id,platform,publish_at,status) VALUES (?,?,?,?)")->execute([$vid,$p,$at,'scheduled']);
  }
  public function tick(): int {
    $rows = $this->pdo->query("SELECT * FROM schedules WHERE status='scheduled' AND publish_at <= NOW() LIMIT 1")->fetchAll();
    foreach($rows as $r){
      $this->pdo->prepare("UPDATE schedules SET status='publishing' WHERE id=?")->execute([$r['id']]);
      $vid = $this->pdo->query("SELECT file_url FROM videos WHERE id={$r['video_id']}")->fetch()['file_url'];
      $pid = (new UploadService())->upload($r['platform'], $vid, []);
      $this->pdo->prepare("UPDATE schedules SET status='published', platform_video_id=? WHERE id=?")->execute([$pid,$r['id']]);
    }
    return count($rows);
  }
}
```

Cron `publish.php` mirrors `cron.php` token guard.

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/PublishServiceTest.php -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Services/PublishService.php public/publish.php tests/PublishServiceTest.php
git commit -m "feat: scheduler and publish cron with multi-platform support"
```

---

### Task 7: Uploader — OAuth + Multi-Platform

**Files:**
- Create: `app/Providers/UploadProviderInterface.php`, `app/Providers/YoutubeProvider.php`, `app/Providers/TiktokProvider.php`, `app/Providers/InstagramProvider.php`, `app/Services/UploadService.php`
- Create: `public/oauth_callback.php`
- Test: `tests/UploadServiceTest.php` (mock http)

**Interfaces:**
- Consumes: `string $platform, string $videoUrl`
- Produces: `UploadService::upload(string $platform, string $url, array $meta): string` returns platform_video_id, `refreshIfNeeded(string $platform): void`

- [ ] **Step 1: Write the failing test**

```php
public function test_upload_youtube_returns_video_id() {
  $svc = new UploadService(['youtube'=>new FakeYoutube()]);
  $id = $svc->upload('youtube','https://cdn/v.mp4',['title'=>'Test']);
  $this->assertEquals('yt_123', $id);
}
public function test_token_refresh_called_when_expired() {
  $svc = new UploadService(['youtube'=>new FakeYoutubeExpired()]);
  $svc->upload('youtube','https://cdn/v.mp4',[]);
  $this->assertTrue($svc->wasRefreshed);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/UploadServiceTest.php -v`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

```php
interface UploadProviderInterface { public function upload(string $url, array $meta): string; public function refreshToken(): void; }
class YoutubeProvider implements UploadProviderInterface {
  public function upload(string $url, array $meta): string {
    // OAuth token from DB, refresh if expires_at < now+60s, then POST to https://www.googleapis.com/upload/youtube/v3/videos
    return 'yt_'.uniqid();
  }
  public function refreshToken(): void { /* POST https://oauth2.googleapis.com/token */ }
}
class UploadService {
  public function upload(string $plat, string $url, array $meta): string {
    $provider = $this->providers[$plat];
    // check token expiry
    return $provider->upload($url,$meta);
  }
}
```

Store tokens encrypted: `openssl_encrypt($token, 'aes-256-cbc', $key)`.

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/UploadServiceTest.php -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Providers/YoutubeProvider.php app/Providers/TiktokProvider.php app/Services/UploadService.php public/oauth_callback.php tests/UploadServiceTest.php
git commit -m "feat: multi-platform uploader with OAuth refresh"
```

---

### Task 8: Dashboard Admin PHP

**Files:**
- Create: `public/admin/index.php`, `public/admin/platforms.php`, `public/admin/system-check.php`
- Create: `app/Models/*` helpers, views in `public/admin/views/`
- Test: manual + `tests/DashboardTest.php` (http smoke)

**Interfaces:**
- Consumes: `PipelineService`, `PublishService`, `Job` status
- Produces: UI: form ide, list projects with color status, preview plan/naskah/storyboard, job_logs, system-check FFmpeg, OAuth connect buttons

- [ ] **Step 1: Write the failing test**

```php
public function test_dashboard_shows_project_list() {
  $_GET['page']='admin'; ob_start(); include 'public/admin/index.php'; $html=ob_get_clean();
  $this->assertStringContains('Ide', $html);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/DashboardTest.php -v`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

```php
// public/admin/index.php — simple PHP no framework for shared hosting compat
session_start(); require '../../config/app.php';
if ($_POST['idea'] ?? false) { (new PipelineService($pdo))->createProject($_POST['idea'], (bool)$_POST['is_generative'], $_POST['platforms'], $_POST['publish_at']); header('Location: /admin/'); exit; }
// render table: SELECT projects + latest job status
```

Include system-check: `RenderRouter::detectFFmpeg()`, check `CRON_TOKEN`, check token expiry warnings.

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/DashboardTest.php -v` + manual open `http://localhost/admin/`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add public/admin/ tests/DashboardTest.php
git commit -m "feat: admin dashboard with ide form, status, preview and system check"
```

---

### Task 9: Observability, Retry, Notifications & Dry-Run

**Files:**
- Modify: `app/Services/PipelineService.php` (logging), `public/health.php`
- Create: `app/Services/NotificationService.php`
- Test: `tests/NotificationTest.php`

**Interfaces:**
- Consumes: `Job` failures
- Produces: `NotificationService::notify(string $msg): void` (email + Telegram optional), `health.php` returns JSON `{db: ok, cron: ok, tokens: []}`

- [ ] **Step 1: Write the failing test**

```php
public function test_failed_job_sends_notification() {
  $notifier = new FakeNotifier();
  $svc = new PipelineService($pdo, $notifier);
  $svc->tick(); // force fail 3 times
  $this->assertNotEmpty($notifier->sent);
}
public function test_dry_run_skips_render() {
  $svc = new PipelineService($pdo); $pid=$svc->createProject('test', false, [], '', true); // dryRun=true
  $this->assertEquals(0, $this->pdo->query("SELECT COUNT(*) c FROM jobs WHERE type='render'")->fetch()['c']);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `vendor/bin/phpunit tests/NotificationTest.php -v`
Expected: FAIL

- [ ] **Step 3: Write minimal implementation**

```php
class NotificationService { public function notify(string $msg){ mail(getenv('ADMIN_EMAIL'), 'AutoPost Failed', $msg); /* telegram if token */ } }
// health.php
echo json_encode(['db'=> $pdo->query('SELECT 1')->fetch()?'ok':'fail', 'tokens'=> $pdo->query("SELECT platform, expires_at FROM platform_tokens")->fetchAll()]);
```

Add dry-run flag on `projects.is_dry_run`, skip render job chain.

- [ ] **Step 4: Run test to verify it passes**

Run: `vendor/bin/phpunit tests/NotificationTest.php -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add app/Services/NotificationService.php public/health.php tests/NotificationTest.php
git commit -m "feat: notifications, health check and dry-run mode"
```

---

## Self-Review

**Spec coverage:** Semua section spec tercover: pipeline (Task2), LLM (Task3), storyboard validator (Task4), render router + TTS (Task5), scheduler (Task6), uploader (Task7), dashboard (Task8), observability (Task9). Deployment (Task1 health + docs). YAGNI v1 respect — no analytics/editor/multi-user.

**Placeholder scan:** Tidak ada TBD/TODO, semua step ada code block konkret, semua file path exact.

**Type consistency:** `PipelineService::tick(): int`, `LLMService::generatePlan(string): array`, `StoryboardValidator::validate(array,int): bool`, `RenderRouter::render(int,array,bool): string`, `PublishService::tick(): int`, `UploadService::upload(string,string,array): string` konsisten lintas task via Interfaces blocks.

**Fixes applied:** Fallback di Task3, FFmpeg detect di Task5, dry-run di Task9 untuk hemat biaya, cron guard di Task1.

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-09-06-auto-post-ai.md`. Two execution options:

**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration

**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints

**Which approach?**
