# v0.2.8 Cut A 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:** F22 (NoteCard 의 회색 placeholder → 실제 `<img>` + 클릭 시 OS viewer) + chore (앱 아이콘 SVG → ICO/ICNS/PNG 다중 size + electron-builder 통합).

**Architecture:** Electron renderer 보안 정책 우회를 위해 main process 에 `inkling-media://` custom protocol 등록 — `<profileDir>/media/<noteId>/<filename>` 을 fetch 가능하게 함. NoteCard 가 protocol URL 을 `<img src>` 로 사용. 클릭 시 IPC `inbox:open-media` → `shell.openPath` 로 OS default viewer 열기. 앱 아이콘은 `assets/icon.svg` (이미 작성) 를 `electron-icon-builder` 로 한 번 빌드 → `build/icon.ico/icns/png` 산출물 git 추적 + electron-builder config 매핑.

**Tech Stack:** Electron 41 protocol API + React 19 + better-sqlite3 + electron-icon-builder + sharp (SVG 변환 fallback)

**선행 spec:** [docs/superpowers/specs/2026-05-09-v028-cut-a-design.md](docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)

---

## File Structure

### 신규 파일

| 경로 | 책임 |
|---|---|
| `src/main/protocol/inklingMedia.ts` | `inkling-media://` scheme 권한 + handler 등록 (path traversal 검사 + inferMime) |
| `tests/unit/inklingMedia.test.ts` | protocol handler 단위 테스트 (path traversal 403 / 정상 200 / 404 / mime) |
| `assets/icon.svg` | (이미 v0.2.7 turn 에서 생성 — Cut A 시작 전 commit 필요 시 재확인) |

### 수정 파일

| 경로 | 변경 |
|---|---|
| `src/main/index.ts` | top-level `protocol.registerSchemesAsPrivileged` + whenReady 안 `registerInklingMediaProtocol(paths.profileDir)` 호출 + `registerInboxApi` 가 신규 IPC 채널 등록 |
| `src/main/ipc/inboxApi.ts` | 신규 IPC `inbox:open-media` 핸들러 (path traversal 검사 + `shell.openPath`) |
| `src/preload/index.ts` | `inbox:open-media` 채널 화이트리스트 |
| `src/shared/types.ts` | `InboxApi` 에 `openMedia(relPath: string): Promise<{ ok: boolean; reason?: string }>` 시그니처 추가 |
| `src/renderer/inbox/api.ts` | inboxApi 객체에 `openMedia` wrapper |
| `src/renderer/inbox/components/NoteCard.tsx` | 회색 `<div>` placeholder → `<img src="inkling-media://...">` + onClick |
| `tests/unit/NoteCard.test.tsx` | 신규 또는 추가 — `<img>` 렌더 + 클릭 시 IPC 호출 |
| `package.json` | devDep `electron-icon-builder` + script `build:icons` + `build.win/mac/linux.icon` 경로 |
| `.gitignore` | `build/` 안 `icon.ico/icns/png` 만 추적 (whitelist) |
| `build/icon.ico` / `build/icon.icns` / `build/icon.png` | 빌드 산출물 commit |
| `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` | F22 진행 상태 🚀 promoted 마킹 |
| `docs/superpowers/v024-backlog.md` | (해당 없음 — Cut A 의 작업이 backlog # 와 매핑 X) |

---

## Phase 개요

```
Phase 1: F22 inkling-media protocol     (Task 1)
Phase 2: F22 NoteCard <img> + 클릭       (Task 2)
Phase 3: F22 IPC inbox:open-media         (Task 3)
Phase 4: chore 앱 아이콘 빌드 + config    (Task 4)
Phase 5: verification + version bump     (Task 5)
```

---

## Phase 1: inkling-media protocol

### Task 1: protocol 등록 + handler + 단위 테스트

**Files:**

- Create: `src/main/protocol/inklingMedia.ts`
- Create: `tests/unit/inklingMedia.test.ts`
- Modify: `src/main/index.ts`

- [ ] **Step 1: failing test 작성**

```ts
// tests/unit/inklingMedia.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join, sep } from 'node:path';

const mockReadFile = vi.fn();
vi.mock('node:fs/promises', () => ({ readFile: mockReadFile }));

const mockHandle = vi.fn();
vi.mock('electron', () => ({
  default: {
    protocol: {
      registerSchemesAsPrivileged: vi.fn(),
      handle: mockHandle
    }
  }
}));

import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';

describe('inferMime', () => {
  it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
  it('returns image/jpeg for .jpg and .jpeg', () => {
    expect(inferMime('foo.jpg')).toBe('image/jpeg');
    expect(inferMime('foo.jpeg')).toBe('image/jpeg');
  });
  it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
  it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
  it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
});

describe('inkling-media protocol handler', () => {
  beforeEach(() => { vi.clearAllMocks(); });

  function getHandler(profileDir: string): (req: Request) => Promise<Response> {
    registerInklingMediaProtocol(profileDir);
    return mockHandle.mock.calls[0][1];
  }

  it('serves valid file with correct mime', async () => {
    mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
    const handler = getHandler('/profile');
    const res = await handler(new Request('inkling-media://media/note1/img.png'));
    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toBe('image/png');
    expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
  });

  it('returns 403 on path traversal attempt', async () => {
    const handler = getHandler('/profile');
    const res = await handler(new Request('inkling-media://media/../etc/passwd'));
    expect(res.status).toBe(403);
    expect(mockReadFile).not.toHaveBeenCalled();
  });

  it('returns 404 when file missing', async () => {
    mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
    const handler = getHandler('/profile');
    const res = await handler(new Request('inkling-media://media/note1/missing.png'));
    expect(res.status).toBe(404);
  });
});
```

- [ ] **Step 2: 테스트 실행 → fail**

```bash
npm run rebuild:node
npx vitest run tests/unit/inklingMedia.test.ts
```

Expected: `Cannot find module '../../src/main/protocol/inklingMedia'`.

- [ ] **Step 3: protocol handler 작성**

```ts
// src/main/protocol/inklingMedia.ts
import electron from 'electron';
import { promises as fs } from 'node:fs';
import { join, normalize, sep, extname } from 'node:path';

const { protocol } = electron;

export function registerSchemesAsPrivileged(): void {
  protocol.registerSchemesAsPrivileged([
    { scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
  ]);
}

export function inferMime(filename: string): string {
  const ext = extname(filename).toLowerCase();
  switch (ext) {
    case '.png': return 'image/png';
    case '.jpg':
    case '.jpeg': return 'image/jpeg';
    case '.gif': return 'image/gif';
    case '.webp': return 'image/webp';
    default: return 'application/octet-stream';
  }
}

export function registerInklingMediaProtocol(profileDir: string): void {
  const mediaRoot = join(profileDir, 'media');
  protocol.handle('inkling-media', async (req) => {
    const url = new URL(req.url);
    // host + pathname 합쳐서 relPath 구성. inkling-media://media/<noteId>/<file> 형식.
    // URL parse 시 host = 'media', pathname = '/<noteId>/<file>'
    const relPath = decodeURIComponent((url.host + url.pathname).replace(/^media\//, 'media/'));
    const target = normalize(join(profileDir, relPath));
    if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
      return new Response(null, { status: 403 });
    }
    try {
      const data = await fs.readFile(target);
      return new Response(new Uint8Array(data), {
        headers: { 'content-type': inferMime(target) }
      });
    } catch {
      return new Response(null, { status: 404 });
    }
  });
}
```

- [ ] **Step 4: 테스트 통과 확인**

```bash
npx vitest run tests/unit/inklingMedia.test.ts
```

Expected: 모든 test pass.

- [ ] **Step 5: src/main/index.ts 통합**

`src/main/index.ts` 의 import 추가:

```ts
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
```

top-level (whenReady 이전, app 생성 직후) 에서:

```ts
registerSchemesAsPrivileged();
```

`whenReady` 안에서 (paths 초기화 후):

```ts
registerInklingMediaProtocol(paths.profileDir);
```

- [ ] **Step 6: typecheck + 전체 회귀**

```bash
npm run typecheck
npx vitest run
```

Expected: 0 errors + 460 → 466 (+6 inferMime 5 + handler 3).

- [ ] **Step 7: commit**

```bash
git add src/main/protocol/inklingMedia.ts tests/unit/inklingMedia.test.ts src/main/index.ts
git commit -m "feat(v028): inkling-media:// custom protocol + path traversal 검사"
```

---

## Phase 2: NoteCard `<img>` + 클릭

### Task 2: NoteCard placeholder → `<img>` 교체

**Files:**

- Modify: `src/renderer/inbox/components/NoteCard.tsx`
- Create: `tests/unit/NoteCard.test.tsx` (없으면 신규)

- [ ] **Step 1: failing test 작성**

```tsx
// tests/unit/NoteCard.test.tsx
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import type { Note } from '@shared/types';

const mockOpenMedia = vi.fn(async () => ({ ok: true }));
vi.mock('../../src/renderer/inbox/api.js', () => ({
  inboxApi: {
    openMedia: mockOpenMedia,
    // 다른 inboxApi 메서드 stub (NoteCard 가 사용할 수 있음)
    permanentDeleteNote: vi.fn(),
    restoreNote: vi.fn()
  }
}));

import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';

const baseNote: Note = {
  id: 'n1',
  rawText: 'test',
  title: 'T',
  summary: '',
  tags: [],
  aiStatus: 'complete',
  createdAt: '2026-05-09T00:00:00Z',
  updatedAt: '2026-05-09T00:00:00Z',
  deletedAt: null,
  media: [
    { id: 'm1', relPath: 'media/n1/img1.png', mime: 'image/png' },
    { id: 'm2', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg' }
  ]
} as unknown as Note;

describe('NoteCard — image rendering', () => {
  beforeEach(() => { vi.clearAllMocks(); cleanup(); });

  it('renders <img> for each media item', () => {
    render(<NoteCard note={baseNote} isTrash={false} />);
    const imgs = screen.getAllByRole('img');
    expect(imgs).toHaveLength(2);
    expect(imgs[0].getAttribute('src')).toBe('inkling-media://media/n1/img1.png');
    expect(imgs[1].getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg');
  });

  it('clicking <img> calls inboxApi.openMedia', () => {
    render(<NoteCard note={baseNote} isTrash={false} />);
    fireEvent.click(screen.getAllByRole('img')[0]);
    expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
  });
});
```

- [ ] **Step 2: 테스트 실행 → fail**

```bash
npx vitest run tests/unit/NoteCard.test.tsx
```

Expected: `getAllByRole('img')` 미존재 (현재 회색 div 만).

- [ ] **Step 3: NoteCard 갱신**

[src/renderer/inbox/components/NoteCard.tsx:334-340](src/renderer/inbox/components/NoteCard.tsx#L334-L340) 의 회색 placeholder div 부분을 다음으로 교체:

```tsx
{local.media.length > 0 && (
  <div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
    {local.media.map((m) => (
      <img
        key={m.id}
        src={`inkling-media://${m.relPath}`}
        alt=""
        title={m.relPath}
        onClick={() => { void inboxApi.openMedia(m.relPath); }}
        style={{
          width: 48,
          height: 48,
          objectFit: 'cover',
          borderRadius: 4,
          cursor: 'pointer',
          border: '1px solid #e0e0e0'
        }}
      />
    ))}
  </div>
)}
```

`inboxApi` import 가 이미 있는지 확인 — 있으면 그대로. 없으면 추가:

```tsx
import { inboxApi } from '../api.js';
```

- [ ] **Step 4: 테스트 통과 + typecheck**

```bash
npx vitest run tests/unit/NoteCard.test.tsx
npm run typecheck
```

Expected: 2 test pass (`openMedia` 가 types.ts 에 아직 없으니 typecheck error 가능 — Task 3 에서 해결). 일단 임시로 NoteCard.test.tsx 의 mock 만 동작.

만약 typecheck error 발생: `inboxApi.openMedia` 가 InboxApi 인터페이스에 없음 → 일시 `(inboxApi as any).openMedia(m.relPath)` 로 cast. **Task 3 에서 정식 시그니처 추가 후 cast 제거.**

- [ ] **Step 5: commit (with TODO note for Task 3)**

```bash
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(v028): NoteCard 이미지 <img> 렌더링 + onClick (openMedia 시그니처는 Task 3)"
```

---

## Phase 3: IPC `inbox:open-media`

### Task 3: main IPC 핸들러 + api.ts wrapper + types

**Files:**

- Modify: `src/main/ipc/inboxApi.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/types.ts`
- Modify: `src/renderer/inbox/api.ts`
- Modify: `src/renderer/inbox/components/NoteCard.tsx` (Task 2 cast 제거)

- [ ] **Step 1: failing test 작성 (IPC handler 단위)**

```ts
// tests/unit/inboxApi-openMedia.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join, sep } from 'node:path';

const handlers: Record<string, Function> = {};
const mockOpenPath = vi.fn(async () => '');

vi.mock('electron', () => ({
  default: {
    ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } },
    shell: { openPath: mockOpenPath }
  }
}));

import { registerInboxApi } from '../../src/main/ipc/inboxApi';

describe('inbox:open-media IPC', () => {
  beforeEach(() => { vi.clearAllMocks(); for (const k of Object.keys(handlers)) delete handlers[k]; });

  it('opens valid relPath with shell.openPath', async () => {
    // 기존 registerInboxApi 가 deps 받으므로 minimal stub 만 — paths 만 사용
    registerInboxApi({ paths: { profileDir: '/profile' } } as any);
    const r = await handlers['inbox:open-media'](null, 'media/note1/img.png');
    expect(r).toEqual({ ok: true });
    expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media/note1/img.png'));
  });

  it('rejects path traversal', async () => {
    registerInboxApi({ paths: { profileDir: '/profile' } } as any);
    const r = await handlers['inbox:open-media'](null, '../etc/passwd');
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('invalid path');
    expect(mockOpenPath).not.toHaveBeenCalled();
  });
});
```

- [ ] **Step 2: 테스트 실행 → fail**

```bash
npx vitest run tests/unit/inboxApi-openMedia.test.ts
```

Expected: handler 미등록.

- [ ] **Step 3: src/main/ipc/inboxApi.ts 핸들러 추가**

기존 `registerInboxApi(deps)` 함수 안에 다음 추가 (다른 IPC 핸들러와 같은 패턴):

```ts
import { join, normalize, sep } from 'node:path';
import electron from 'electron';
const { ipcMain, shell } = electron;

// registerInboxApi 안:
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
  const mediaRoot = join(deps.paths.profileDir, 'media');
  const target = normalize(join(deps.paths.profileDir, relPath));
  if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
    return { ok: false, reason: 'invalid path' };
  }
  await shell.openPath(target);
  return { ok: true };
});
```

(기존 deps 타입에 `paths.profileDir` 가 있는지 확인. 없으면 SettingsIpcDeps 와 비슷한 형태로 추가.)

- [ ] **Step 4: src/shared/types.ts InboxApi 갱신**

```ts
// 기존 InboxApi 인터페이스 안:
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
```

- [ ] **Step 5: preload + api.ts wrapper**

`src/preload/index.ts` 의 invoke whitelist 또는 API expose 객체 안에 `'inbox:open-media'` 추가 (기존 `'inbox:*'` 채널 패턴 따름).

`src/renderer/inbox/api.ts` 의 inboxApi 객체에 추가:

```ts
async openMedia(relPath: string) {
  return await window.inkling.invoke('inbox:open-media', relPath);
}
```

(또는 기존 wildcard re-export 사용 시 자동 노출 — `feb7c62` commit 의 `onNavigate` 처럼.)

- [ ] **Step 6: NoteCard.tsx cast 제거**

Task 2 에서 임시 `(inboxApi as any).openMedia` cast 했다면 → `inboxApi.openMedia` 정상 사용으로 변경. typecheck 통과 확인.

- [ ] **Step 7: 테스트 + typecheck + 회귀**

```bash
npx vitest run tests/unit/inboxApi-openMedia.test.ts
npm run typecheck
npx vitest run
```

Expected: IPC 2 test pass + 466 → 468 (+2) + 0 typecheck.

- [ ] **Step 8: commit**

```bash
git add -A src/main/ipc/ src/preload/ src/shared/ src/renderer/ tests/unit/inboxApi-openMedia.test.ts
git commit -m "feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리"
```

---

## Phase 4: 앱 아이콘 빌드 + config

### Task 4: electron-icon-builder + 산출물 + builder config

**Files:**

- Modify: `package.json`
- Modify: `.gitignore`
- Create: `build/icon.ico`, `build/icon.icns`, `build/icon.png` (빌드 산출물)
- (조건부) Create: `scripts/svg-to-png.mjs` (SVG → PNG fallback if needed)

- [ ] **Step 1: devDep 설치**

```bash
npm install --save-dev electron-icon-builder
```

확인: `package.json` 의 devDependencies 에 `"electron-icon-builder": "^2.x.x"` 추가됨.

- [ ] **Step 2: package.json scripts + builder config**

`package.json` 의 scripts 블록에 추가:

```json
"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten"
```

(만약 SVG 직접 input 안 되면 — Step 3 에서 sharp fallback 으로 분기.)

`build` 블록 갱신 (기존 win/mac/linux 에 icon 키 추가):

```json
"win": { "icon": "build/icon.ico", ... 기존 그대로 ... },
"mac": { "icon": "build/icon.icns", ... 기존 그대로 ... },
"linux": { "icon": "build/icon.png", ... 기존 그대로 ... }
```

- [ ] **Step 3: 빌드 실행**

```bash
npm run build:icons
```

Expected:
- `build/icon.ico` (Win)
- `build/icon.icns` (macOS)
- `build/icon.png` (1024x1024, Linux)

만약 SVG 직접 안 되면 (electron-icon-builder 가 PNG 만 input 받음):

1. `scripts/svg-to-png.mjs` 작성:

```js
import sharp from 'sharp';
import { readFileSync, writeFileSync } from 'node:fs';

const [,, input, output, size = '1024'] = process.argv;
const svg = readFileSync(input);
const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer();
writeFileSync(output, png);
console.log(`OK: ${output} (${size}x${size})`);
```

2. `package.json` scripts 갱신:

```json
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten"
```

3. `npm install --save-dev sharp` (이미 있으면 skip).

4. 다시 `npm run build:icons` 실행.

- [ ] **Step 4: .gitignore 갱신 (build/ 안 산출물 whitelist)**

`.gitignore` 의 `build/` 또는 `dist/` 항목 확인. 만약 `build/` 가 ignore 되어 있다면:

```
build/
!build/icon.ico
!build/icon.icns
!build/icon.png
```

(만약 `build/` 가 ignore 안 되어 있다면 — 모두 추적 가능 — Step 4 skip.)

- [ ] **Step 5: 산출물 확인 + commit**

```bash
ls -la build/icon.ico build/icon.icns build/icon.png
```

Expected: 3 파일 모두 size 양수 (수십 KB 이상).

```bash
git add package.json package-lock.json .gitignore scripts/svg-to-png.mjs build/icon.ico build/icon.icns build/icon.png
git commit -m "chore(v028): 앱 아이콘 (assets/icon.svg → ICO/ICNS/PNG) + electron-builder config"
```

(scripts/svg-to-png.mjs 는 sharp fallback 사용 시만 추가.)

- [ ] **Step 6: typecheck + 전체 회귀**

```bash
npm run typecheck
npx vitest run
```

Expected: 0 errors + 468 pass (이전 + Task 1-3 변경 포함). 아이콘 변경은 테스트 영향 X.

---

## Phase 5: verification + version bump

### Task 5: 회귀 + dogfood-feedback 마킹 + version bump

**Files:**

- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F22 promoted 마킹)
- Modify: `package.json` (version 0.2.7 → 0.2.8)

- [ ] **Step 1: 단위 + e2e + typecheck 일괄**

```bash
npm run rebuild:node
npm test
npm run typecheck
npm run rebuild:electron
npm run test:e2e
```

Expected: 모두 pass. 단위 460 → 약 468 (+8: inferMime 5 + protocol handler 3 + NoteCard img 2 + IPC 2 = 12, 일부 mock 충돌 가능 — 실제 카운트 ±). e2e 1/1.

- [ ] **Step 2: 수동 launch 검증 (Win + macOS 가능 시)**

```bash
npm run start
```

체크리스트:

- inbox 의 capture-with-image 노트의 thumbnail = 실제 이미지 (회색 사각형 X)
- thumbnail 클릭 → OS default viewer (예: Win Photos / macOS Preview) 열림
- 새 아이콘이 트레이 / Windows taskbar / dock 정확 표시
- 다중 이미지 노트의 grid layout (flex-wrap) 자연스러움

- [ ] **Step 3: dogfood-feedback.md F22 promoted 마킹**

`docs/superpowers/specs/2026-04-25-dogfood-feedback.md` 의 F22 entry 헤더 갱신:

```markdown
## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
```

진행 상태 line 도 `🚀 promoted` 로 갱신.

- [ ] **Step 4: package.json version bump**

```json
"version": "0.2.8"
```

- [ ] **Step 5: commit**

```bash
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json
git commit -m "chore(release): v0.2.8 — Cut A (이미지 렌더링 + 앱 아이콘)"
```

---

## Self-Review

**Spec coverage:**

| Spec 섹션 | task |
|---|---|
| §3-1 protocol 등록 | Task 1 |
| §3-2 NoteCard `<img>` | Task 2 |
| §3-3 IPC inbox:open-media | Task 3 |
| §3-4 보안 (path traversal) | Task 1 + Task 3 |
| §4-1 devDep + scripts | Task 4 |
| §4-2 builder config | Task 4 |
| §4-3 산출물 git 추적 | Task 4 |
| §4-4 SVG → PNG fallback | Task 4 (조건부) |
| §5 테스트 | 각 task 안 단위 + Task 5 회귀 |
| §6 Risk | Task 4 fallback 분기 |

모든 spec 요구가 task 매핑됨.

**Placeholder scan**: "TBD" / "TODO" / "implement later" 없음. 각 step 의 코드/명령 실행 가능 형태.

**Type consistency**:

- `InboxApi.openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>` — Task 3 정의, Task 2 cast → Task 3 cast 제거 일관.
- `inferMime(filename: string): string` — Task 1 정의, Task 1 안 사용.
- `registerInklingMediaProtocol(profileDir: string): void` / `registerSchemesAsPrivileged(): void` — Task 1 export, src/main/index.ts import 일관.

이슈 없음.

---

## Execution Handoff

Plan 작성 완료, `docs/superpowers/plans/2026-05-09-v028-cut-a.md` 저장.

두 가지 실행 옵션:

**1. Subagent-Driven (recommended)** — fresh subagent per task, two-stage review (spec compliance + code quality), 빠른 iteration

**2. Inline Execution** — 본 세션에서 task 일괄 실행 + checkpoint 마다 review

어느 쪽?
