# v0.2.9 Cut B Implementation Plan — status 4분기 + 사유 + Ollama-less

> **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:** F17 (status 4분기 + AI 자동 분류) + F18 (자유 텍스트 사유) + F23 (Ollama-less 모드 onboarding wizard + 설정 토글 + raw-only NoteCard fallback) — 데이터 모델 정비 cut.

**Architecture:** notes 테이블에 `status` 컬럼 (4분기: active/completed/archived/trashed) + `move_reason` 자유 텍스트 + `status_changed_at` 추가. ai_status enum 에 `disabled` 신규 값 (application-level zod 검증). useInbox `view` enum 4탭 확장. NoteCard 액션 메뉴 + 사유 입력 modal + AI 자동 분류 버튼 (사유 + raw_text → AI prompt → recommended status + rationale → 사용자 confirm). 첫 launch onboarding wizard (3 옵션: AI 사용 / 원문만 / 나중에) + 설정 페이지 "AI 자동 처리 사용" 토글 + ai_enabled false 시 capture path skip pending_jobs + Banner 비활성.

**Tech Stack:** Electron 41 + React 19 + better-sqlite3 12.9 + zod 4.3.6 + zustand 5

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

---

## File Structure

### 신규 파일

| 경로 | 책임 |
|---|---|
| `src/main/db/migrations/m004.ts` | status 컬럼 + status_changed_at + move_reason 추가 + 기존 deleted_at != NULL → status='trashed' migrate |
| `src/renderer/inbox/components/MoveStatusModal.tsx` | 사유 입력 modal + 4 status 버튼 + AI 자동 분류 버튼 |
| `src/renderer/inbox/components/OnboardingWizard.tsx` | 첫 launch 3 옵션 onboarding |
| `src/main/ai/classifyStatus.ts` | AiWorker.classifyStatus — reason + raw_text + summary → AI prompt → { recommended, rationale } |
| 위 컴포넌트들의 `.test.ts(x)` | 단위 테스트 (vitest + RTL) |

### 수정 파일

| 경로 | 변경 |
|---|---|
| `src/main/db/index.ts` | m004 migration 등록 |
| `src/shared/types.ts` | `NoteStatus` enum + `Note.status/statusChangedAt/moveReason` field + `InboxApi.setStatus` / `classifyStatus` 시그니처 + `AiStatus` 에 'disabled' enum 추가 |
| `src/main/repository/NoteRepository.ts` | `setStatus(id, status, reason)` / `listByStatus(status, opts)` 메서드 추가, 기존 `restoreNote` → `setStatus(id, 'active', null)` 재구현 |
| `src/main/services/CaptureService.ts` | `ai_enabled` settings 조회 → false 시 ai_status='disabled' + pending_jobs skip |
| `src/main/services/SettingsService.ts` | settings 스키마에 `ai_enabled: boolean (default true)` + `onboarding_completed: boolean (default false)` 추가 |
| `src/main/ai/AiWorker.ts` | `classifyStatus` 메서드 추가 (현 generate 와 별도 path) |
| `src/main/health/HealthChecker.ts` | ai_enabled=false 시 polling 중단 |
| `src/main/ipc/inboxApi.ts` | `inbox:set-status` + `ai:classify-status` IPC 핸들러 |
| `src/main/ipc/settingsApi.ts` | settings 에 ai_enabled / onboarding_completed 토글 IPC |
| `src/preload/index.ts` | 신규 채널 화이트리스트 |
| `src/renderer/inbox/api.ts` | wrapper (setStatus / classifyStatus / setAiEnabled / setOnboardingCompleted) |
| `src/renderer/inbox/store.ts` | view enum 확장 (`inbox`/`completed`/`archived`/`trash`/`settings`) + count fields per status |
| `src/renderer/inbox/App.tsx` | 헤더 4탭 + Onboarding wizard 첫 launch 분기 |
| `src/renderer/inbox/components/NoteCard.tsx` | 액션 메뉴 (완료/보관/휴지통) + ai_status='disabled' fallback (title = raw_text 첫 줄) |
| `src/renderer/inbox/components/OllamaBanner.tsx` + `FailedBanner.tsx` | ai_enabled=false 시 render skip |
| `src/renderer/inbox/components/settings/AiProviderSection.tsx` | 상단 "AI 자동 처리 사용" 토글 + disabled 메모 N건 처리 버튼 |

---

## Phase 개요

```
Phase 1: m004 schema + repo (Task 1, 2)
Phase 2: ai_status 'disabled' + capture skip (Task 3)
Phase 3: 4탭 UI 인프라 (Task 4, 5)
Phase 4: 사유 입력 modal (Task 6, 7, 8)
Phase 5: AI 자동 분류 (Task 9, 10)
Phase 6: Onboarding wizard (Task 11, 12)
Phase 7: AI off NoteCard fallback + Banner 비활성 (Task 13, 14)
Phase 8: 설정 페이지 토글 + disabled 메모 처리 (Task 15, 16)
Phase 9: verification + version bump (Task 17)
```

---

## Phase 1: m004 schema + NoteRepository

### Task 1: m004 마이그레이션 — status 컬럼 추가

**Files:**

- Create: `src/main/db/migrations/m004.ts`
- Modify: `src/main/db/index.ts` (migration 등록)
- Test: `tests/unit/m004-migration.test.ts`

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

```ts
// tests/unit/m004-migration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { applyM004 } from '../../src/main/db/migrations/m004';

describe('m004 migration — status column', () => {
  let db: Database.Database;

  beforeEach(() => {
    db = new Database(':memory:');
    // m003 baseline (notes 테이블 with deleted_at) 가정 — 기존 마이그레이션 적용
    db.exec(`
      CREATE TABLE notes (
        id TEXT PRIMARY KEY,
        raw_text TEXT NOT NULL,
        title TEXT,
        summary TEXT,
        tags_csv TEXT,
        ai_status TEXT NOT NULL DEFAULT 'pending',
        ai_error TEXT,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        deleted_at TEXT
      );
      INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, deleted_at)
        VALUES ('a', 't1', 'complete', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', NULL),
               ('b', 't2', 'complete', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z', '2026-05-08T00:00:00Z');
    `);
  });

  it('adds status / status_changed_at / move_reason columns', () => {
    applyM004(db);
    const cols = db.prepare(`PRAGMA table_info(notes)`).all() as Array<{ name: string }>;
    const names = cols.map(c => c.name);
    expect(names).toContain('status');
    expect(names).toContain('status_changed_at');
    expect(names).toContain('move_reason');
  });

  it('default status="active" for non-deleted notes', () => {
    applyM004(db);
    const a = db.prepare(`SELECT status FROM notes WHERE id=?`).get('a') as { status: string };
    expect(a.status).toBe('active');
  });

  it('migrates deleted_at != NULL to status="trashed" + status_changed_at', () => {
    applyM004(db);
    const b = db.prepare(`SELECT status, status_changed_at FROM notes WHERE id=?`).get('b') as { status: string; status_changed_at: string };
    expect(b.status).toBe('trashed');
    expect(b.status_changed_at).toBe('2026-05-08T00:00:00Z');
  });

  it('move_reason NULL by default', () => {
    applyM004(db);
    const a = db.prepare(`SELECT move_reason FROM notes WHERE id=?`).get('a') as { move_reason: string | null };
    expect(a.move_reason).toBeNull();
  });
});
```

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

```bash
npm run rebuild:node
npx vitest run tests/unit/m004-migration.test.ts
```

Expected: `Cannot find module '../../src/main/db/migrations/m004'`.

- [ ] **Step 3: m004.ts 작성**

```ts
// src/main/db/migrations/m004.ts
import type Database from 'better-sqlite3';

export function applyM004(db: Database.Database): void {
  db.exec(`
    ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
    ALTER TABLE notes ADD COLUMN status_changed_at TEXT;
    ALTER TABLE notes ADD COLUMN move_reason TEXT;
  `);
  // 기존 deleted_at != NULL → status='trashed' + status_changed_at = deleted_at
  db.prepare(`
    UPDATE notes SET status='trashed', status_changed_at=deleted_at
    WHERE deleted_at IS NOT NULL
  `).run();
}
```

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

```bash
npx vitest run tests/unit/m004-migration.test.ts
```

Expected: 4 tests pass.

- [ ] **Step 5: src/main/db/index.ts 의 migration registry 갱신**

기존 `applyM003` 다음에 `applyM004` 추가. 마이그레이션 호출 chain (주로 `runMigrations()` 함수 안 또는 비슷한 곳) 에 m004 등록. 기존 패턴 따름.

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

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

Expected: 0 errors + 472 → 476 (+4).

- [ ] **Step 7: commit**

```bash
git add src/main/db/migrations/m004.ts src/main/db/index.ts tests/unit/m004-migration.test.ts
git commit -m "feat(v029): m004 마이그레이션 — status/status_changed_at/move_reason 컬럼"
```

---

### Task 2: NoteRepository.setStatus + listByStatus + restoreNote 재구현

**Files:**

- Modify: `src/main/repository/NoteRepository.ts`
- Modify: `src/shared/types.ts` (NoteStatus + Note 필드)
- Modify: `tests/unit/NoteRepository.test.ts`

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

```ts
// tests/unit/NoteRepository.test.ts 안 (기존 describe 블록에 추가)
describe('NoteRepository — setStatus + listByStatus', () => {
  // (기존 setup helper 활용)

  it('setStatus updates status + reason + status_changed_at', () => {
    const noteId = repo.create({ rawText: 'test', now: new Date('2026-05-09T00:00:00Z') }).id;
    repo.setStatus(noteId, 'completed', '결재 끝', new Date('2026-05-10T00:00:00Z'));
    const note = repo.getById(noteId);
    expect(note?.status).toBe('completed');
    expect(note?.moveReason).toBe('결재 끝');
    expect(note?.statusChangedAt).toBe('2026-05-10T00:00:00Z');
  });

  it('listByStatus filters correctly', () => {
    repo.create({ rawText: 'a', now: new Date() });
    const idB = repo.create({ rawText: 'b', now: new Date() }).id;
    repo.setStatus(idB, 'archived', null, new Date());

    const active = repo.listByStatus('active', { limit: 10 });
    const archived = repo.listByStatus('archived', { limit: 10 });
    expect(active.map(n => n.rawText)).toContain('a');
    expect(archived.map(n => n.rawText)).toContain('b');
  });

  it('restoreNote sets status=active with null reason', () => {
    const id = repo.create({ rawText: 'r', now: new Date() }).id;
    repo.setStatus(id, 'trashed', null, new Date());
    repo.restoreNote(id);
    const note = repo.getById(id);
    expect(note?.status).toBe('active');
    expect(note?.moveReason).toBeNull();
  });
});
```

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

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

Expected: setStatus / listByStatus 미존재 → fail.

- [ ] **Step 3: types.ts 갱신**

```ts
// src/shared/types.ts
export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed';

export interface Note {
  // ... 기존 필드
  status: NoteStatus;
  statusChangedAt: string | null;
  moveReason: string | null;
}
```

- [ ] **Step 4: NoteRepository 메서드 추가**

```ts
// src/main/repository/NoteRepository.ts
setStatus(id: string, status: NoteStatus, reason: string | null, now: Date = new Date()): void {
  const tx = this.db.transaction(() => {
    this.db.prepare(`
      UPDATE notes
      SET status=?, move_reason=?, status_changed_at=?, updated_at=?
      WHERE id=?
    `).run(status, reason, now.toISOString(), now.toISOString(), id);
    // status='trashed' 시 deleted_at 도 동기화 (backward compat — 기존 코드 가 deleted_at 참조)
    if (status === 'trashed') {
      this.db.prepare(`UPDATE notes SET deleted_at=? WHERE id=?`).run(now.toISOString(), id);
    } else {
      this.db.prepare(`UPDATE notes SET deleted_at=NULL WHERE id=?`).run(id);
    }
  });
  tx();
}

listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] {
  const limit = opts.limit ?? 200;
  const rows = this.db.prepare(`
    SELECT * FROM notes WHERE status=?
    ORDER BY status_changed_at DESC, created_at DESC
    LIMIT ?
  `).all(status, limit);
  return rows.map(r => this.hydrate(r as Record<string, unknown>));
}
```

`restoreNote` 재구현 — 기존 `repo.restoreNote(id)` 본문을 `setStatus(id, 'active', null)` 로 교체:

```ts
restoreNote(id: string): void {
  this.setStatus(id, 'active', null);
  // 기존 m003 의 ai_status='failed' → 'pending' 재투입 로직 보존
  const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id=?`).get(id) as { ai_status: string } | undefined;
  if (before?.ai_status === 'failed') {
    const now = new Date().toISOString();
    this.db.prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`).run(now, id);
    this.db.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, now);
  } else if (before?.ai_status === 'pending') {
    const now = new Date().toISOString();
    this.db.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, now);
  }
}
```

`hydrate` 메서드도 status / status_changed_at / move_reason 매핑 추가:

```ts
private hydrate(row: Record<string, unknown>): Note {
  return {
    // ... 기존
    status: (row.status as NoteStatus) ?? 'active',
    statusChangedAt: (row.status_changed_at as string | null) ?? null,
    moveReason: (row.move_reason as string | null) ?? null
  };
}
```

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

```bash
npx vitest run tests/unit/NoteRepository.test.ts
npm run typecheck
npx vitest run
```

Expected: 신규 3 test pass + 회귀 476 → 479.

- [ ] **Step 6: commit**

```bash
git add src/main/repository/NoteRepository.ts src/shared/types.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(v029): NoteRepository.setStatus + listByStatus + restoreNote 재구현"
```

---

## Phase 2: ai_status='disabled' + capture skip

### Task 3: ai_status 'disabled' enum + CaptureService aiEnabled 분기

**Files:**

- Modify: `src/main/services/telemetryEvents.ts` (또는 ai_status enum 위치) — 'disabled' 추가
- Modify: `src/main/services/SettingsService.ts` — `ai_enabled` field schema
- Modify: `src/main/services/CaptureService.ts` — aiEnabled 분기
- Modify: `tests/unit/CaptureService.test.ts`

- [ ] **Step 1: ai_status enum 'disabled' 추가**

`src/shared/types.ts` 또는 ai_status 정의 위치 (현재 v0.2.6 의 `AiFailedReason` 통합 commit 위치 참조):

```ts
// 기존 AiStatus enum 또는 zod schema:
export const AiStatusSchema = z.enum(['pending', 'processing', 'complete', 'failed', 'disabled']);
export type AiStatus = z.infer<typeof AiStatusSchema>;
```

만약 ai_status 가 application-level 검증만이고 SQLite CHECK 부재 (spec §5-1) — application zod 추가만으로 충분.

- [ ] **Step 2: SettingsService schema 갱신**

```ts
// src/main/services/SettingsService.ts (또는 schema 정의 위치)
export const SettingsSchema = z.object({
  // ... 기존
  ai_enabled: z.boolean().default(true),
  onboarding_completed: z.boolean().default(false)
});
export type Settings = z.infer<typeof SettingsSchema>;
```

기존 SettingsService 의 get/set 메서드 가 새 field 자동 인식. zod default 가 fallback.

- [ ] **Step 3: failing test (CaptureService 분기)**

```ts
// tests/unit/CaptureService.test.ts 추가
describe('CaptureService — ai_enabled false', () => {
  it('skips pending_jobs enqueue when ai_enabled=false', async () => {
    const settings = makeMockSettings({ ai_enabled: false });
    const enqueue = vi.fn(async () => {});
    const svc = new CaptureService({ repo, settings, enqueue, /* 기타 deps */ });
    const r = await svc.create({ rawText: 'test' });
    expect(enqueue).not.toHaveBeenCalled();
    const note = repo.getById(r.id);
    expect(note?.aiStatus).toBe('disabled');
  });

  it('enqueues normally when ai_enabled=true (default)', async () => {
    const settings = makeMockSettings({ ai_enabled: true });
    const enqueue = vi.fn(async () => {});
    const svc = new CaptureService({ repo, settings, enqueue });
    const r = await svc.create({ rawText: 'test' });
    expect(enqueue).toHaveBeenCalledWith(r.id);
  });
});
```

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

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

- [ ] **Step 5: CaptureService 갱신**

```ts
// src/main/services/CaptureService.ts
async create(input: { rawText: string; images?: ... }): Promise<{ id: string }> {
  const aiEnabled = await this.deps.settings.get('ai_enabled', true);
  const initialAiStatus = aiEnabled ? 'pending' : 'disabled';
  const note = this.deps.repo.insert({ ...input, aiStatus: initialAiStatus });
  if (aiEnabled) {
    await this.deps.enqueue(note.id);
  }
  return { id: note.id };
}
```

(deps 인터페이스에 `settings: SettingsService` 추가 — main process 가 inject. NoteRepository.insert 의 `aiStatus` 인자 가 신규 — repo 도 갱신.)

NoteRepository.insert 의 INSERT 문 — `ai_status` 가 input 으로 받도록 갱신:

```ts
insert(input: { ..., aiStatus?: AiStatus }): Note {
  const ai_status = input.aiStatus ?? 'pending';
  this.db.prepare(`INSERT INTO notes (..., ai_status, ...) VALUES (..., ?, ...)`).run(..., ai_status, ...);
}
```

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

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

Expected: 신규 2 test + 기존 회귀 479 → 481.

- [ ] **Step 7: commit**

```bash
git add src/shared/types.ts src/main/services/SettingsService.ts src/main/services/CaptureService.ts src/main/repository/NoteRepository.ts tests/unit/CaptureService.test.ts
git commit -m "feat(v029): ai_status 'disabled' enum + CaptureService ai_enabled 분기 (skip pending_jobs)"
```

---

## Phase 3: 4탭 UI 인프라

### Task 4: useInbox view enum + count fields per status

**Files:**

- Modify: `src/renderer/inbox/store.ts`
- Modify: `tests/unit/store.showSettings.test.ts` (또는 store.test.ts 신규)

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

```ts
// tests/unit/store.view.test.ts (신규)
import { describe, it, expect, beforeEach } from 'vitest';
import { useInbox } from '../../src/renderer/inbox/store';

describe('inbox store — view enum', () => {
  beforeEach(() => {
    useInbox.setState({
      view: 'inbox',
      counts: { active: 0, completed: 0, archived: 0, trashed: 0 }
    });
  });

  it('initial view is inbox', () => {
    expect(useInbox.getState().view).toBe('inbox');
  });

  it('setView changes view', () => {
    useInbox.getState().setView('completed');
    expect(useInbox.getState().view).toBe('completed');
  });

  it('counts initialized to zero per status', () => {
    expect(useInbox.getState().counts).toEqual({ active: 0, completed: 0, archived: 0, trashed: 0 });
  });
});
```

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

- [ ] **Step 3: store.ts 갱신**

기존 `showTrash: boolean` + `showSettings: boolean` 2개 boolean 을 `view: 'inbox' | 'completed' | 'archived' | 'trash' | 'settings'` 로 통합:

```ts
// src/renderer/inbox/store.ts
type InboxView = 'inbox' | 'completed' | 'archived' | 'trash' | 'settings';

interface InboxState {
  // ... 기존
  view: InboxView;
  counts: { active: number; completed: number; archived: number; trashed: number };
  setView: (view: InboxView) => void;
  // 기존 toggleShowTrash / showTrash / showSettings / setShowSettings — DEPRECATED. setView 로 통합.
  // backward compat 위해 일시 잔류 가능 OR 즉시 제거 (caller 갱신 필요).
}

// initial:
view: 'inbox',
counts: { active: 0, completed: 0, archived: 0, trashed: 0 },

// action:
setView(view) { set({ view }); /* 필요 시 데이터 fetch */ },
```

기존 `showTrash` / `showSettings` 와 `toggleShowTrash` / `setShowSettings` 호출부 모두 `view` enum 으로 갱신 — Task 5 에서 본격 처리.

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

기존 `showTrash` 사용처가 broken — Task 5 에서 정리할 것이므로 잠시 broken state 받아들임. 또는 backward-compat 으로 `showTrash: get().view === 'trash'` computed 형태로 잠시 유지 가능.

추천: backward-compat — store 안에 `get showTrash() { return this.view === 'trash'; }` 같은 getter 또는 selector 함수 추가. caller 들 점진적 갱신.

```ts
// 임시 backward-compat (Task 5 머지 후 제거)
showTrash: false, // computed below
showSettings: false,
// store update 시 view 변경할 때 같이 갱신:
setView(view) {
  set({ view, showTrash: view === 'trash', showSettings: view === 'settings' });
}
```

- [ ] **Step 5: commit**

```bash
git add src/renderer/inbox/store.ts tests/unit/store.view.test.ts
git commit -m "feat(v029): inbox store view enum 4탭 + counts per status (backward-compat showTrash/showSettings)"
```

---

### Task 5: 헤더 4탭 + count badge

**Files:**

- Modify: `src/renderer/inbox/App.tsx`
- Modify: `tests/unit/App.test.tsx`

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

```tsx
// tests/unit/App.test.tsx — 추가
describe('App header — 4 tabs', () => {
  it('renders 4 tabs (Inbox / 완료 / 보관 / 휴지통)', () => {
    render(<App />);
    expect(screen.getByRole('button', { name: /Inbox/ })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /^완료/ })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /^보관/ })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /^휴지통/ })).toBeInTheDocument();
  });

  it('clicking 완료 tab sets view=completed', () => {
    render(<App />);
    fireEvent.click(screen.getByRole('button', { name: /^완료/ }));
    expect(useInbox.getState().view).toBe('completed');
  });
});
```

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

- [ ] **Step 3: App.tsx 갱신 — 4탭**

기존 2탭 (Inbox / 휴지통) → 4탭. 기존 tabBtnStyle 유지.

```tsx
const view = useInbox(s => s.view);
const setView = useInbox(s => s.setView);
const counts = useInbox(s => s.counts);

const tabs: Array<{ key: InboxView; label: string; count: number }> = [
  { key: 'inbox', label: 'Inbox', count: counts.active },
  { key: 'completed', label: '완료', count: counts.completed },
  { key: 'archived', label: '보관', count: counts.archived },
  { key: 'trash', label: '휴지통', count: counts.trashed }
];

// 헤더 안:
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
  {tabs.map(t => (
    <button
      key={t.key}
      onClick={() => setView(t.key)}
      aria-pressed={view === t.key}
      style={tabBtnStyle(view === t.key)}
    >
      {t.label}({t.count})
    </button>
  ))}
</div>
```

view='settings' 분기는 기존대로 유지. notes list rendering 시 `useInbox(s => s.notes)` 가 current view 에 맞는 노트 (active / completed / archived / trash) 자동 반환되도록 store action 갱신 — `loadInitial` / `setView` 시 해당 status fetch.

- [ ] **Step 4: store loadInitial + setView 갱신**

```ts
// store.ts — setView 가 해당 view 의 notes fetch
setView(view) {
  set({ view, showTrash: view === 'trash', showSettings: view === 'settings' });
  if (view !== 'settings' && view !== 'inbox') {
    void get().loadByView(view);  // 새 helper
  } else if (view === 'inbox') {
    void get().loadInitial();
  }
}

async loadByView(view: 'completed' | 'archived' | 'trash'): Promise<void> {
  const status = view === 'trash' ? 'trashed' : view;
  const notes = await inboxApi.listByStatus(status, { limit: 200 });
  set({ notes });  // 또는 별도 cache map per status
}
```

(IPC `inbox:list-by-status` 도 신규 — Task 6 또는 본 task 에서 추가 가능. 본 task 에서 함께.)

- [ ] **Step 5: IPC 핸들러 추가**

```ts
// src/main/ipc/inboxApi.ts
ipcMain.handle('inbox:list-by-status', async (_e, status: NoteStatus, opts: { limit?: number }) => {
  return repo.listByStatus(status, opts);
});
```

api.ts wrapper + preload + types.ts InboxApi 갱신:

```ts
listByStatus(status: NoteStatus, opts: { limit?: number }): Promise<Note[]>;
```

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

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

Expected: 회귀 481 → 484 (+3).

- [ ] **Step 7: commit**

```bash
git add src/renderer/inbox/App.tsx src/renderer/inbox/store.ts src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts src/renderer/inbox/api.ts tests/unit/App.test.tsx
git commit -m "feat(v029): 헤더 4탭 (Inbox/완료/보관/휴지통) + listByStatus IPC"
```

---

## Phase 4: 사유 입력 modal

### Task 6: NoteCard 액션 메뉴 (이동 버튼)

**Files:**

- Modify: `src/renderer/inbox/components/NoteCard.tsx`
- Modify: `tests/unit/NoteCard.test.tsx`

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

```tsx
it('renders move action menu (완료 / 보관 / 휴지통)', () => {
  render(<NoteCard note={baseNote} mode="inbox" onUpdated={vi.fn()} />);
  fireEvent.click(screen.getByRole('button', { name: /이동/ }));
  expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
  expect(screen.getByRole('button', { name: '보관함으로 이동' })).toBeInTheDocument();
  expect(screen.getByRole('button', { name: '휴지통으로 이동' })).toBeInTheDocument();
});

it('clicking "완료로 이동" opens MoveStatusModal with status preset', () => {
  render(<NoteCard note={baseNote} mode="inbox" onUpdated={vi.fn()} />);
  fireEvent.click(screen.getByRole('button', { name: /이동/ }));
  fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
  expect(screen.getByRole('dialog', { name: /이동/ })).toBeInTheDocument();
});
```

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

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

기존 휴지통 버튼 1개 → 메뉴 (dropdown 또는 inline group):

```tsx
import { useState } from 'react';
import { MoveStatusModal } from './MoveStatusModal';

// NoteCard 안:
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
const [menuOpen, setMenuOpen] = useState(false);

// 기존 휴지통 버튼 자리 — view='inbox' 이면:
{mode === 'inbox' && (
  <div style={{ position: 'relative' }}>
    <button onClick={() => setMenuOpen(o => !o)} aria-label="이동" style={{ ... }}>이동 ▾</button>
    {menuOpen && (
      <div style={{ position: 'absolute', right: 0, top: '100%', background: '#fff', border: '1px solid #ccc', borderRadius: 4, padding: 4 }}>
        <button onClick={() => { setMoveTarget('completed'); setMenuOpen(false); }}>완료로 이동</button>
        <button onClick={() => { setMoveTarget('archived'); setMenuOpen(false); }}>보관함으로 이동</button>
        <button onClick={() => { setMoveTarget('trashed'); setMenuOpen(false); }}>휴지통으로 이동</button>
      </div>
    )}
  </div>
)}

{moveTarget && (
  <MoveStatusModal
    noteId={local.id}
    rawText={local.rawText}
    summary={local.summary ?? ''}
    initialTarget={moveTarget}
    onClose={() => setMoveTarget(null)}
    onMoved={(newStatus, reason) => {
      onUpdated({ ...local, status: newStatus, moveReason: reason });
      setMoveTarget(null);
    }}
  />
)}
```

mode='completed'/'archived' 등 다른 view 의 NoteCard 도 비슷한 메뉴 (예: 보관에서 active 로 복귀, 휴지통에서 restore). MoveStatusModal 의 inverse 동작 — 같은 컴포넌트 활용.

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

MoveStatusModal 미존재라 import error — Task 7 에서 작성. 임시로 stub component 또는 skip 검사.

```tsx
// 임시 stub — Task 7 까지 jsdom 에서 안 깨지도록:
function MoveStatusModal(props: any) { return <div role="dialog" aria-label="이동">stub</div>; }
```

또는 MoveStatusModal 을 Task 7 에서 작성 후 NoteCard import 갱신 — 한 PR 안 두 commit 분리.

추천: Task 6 안 NoteCard 갱신 + 임시 inline stub MoveStatusModal — Task 7 에서 별 파일로 추출 + 정식 구현.

- [ ] **Step 5: commit**

```bash
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(v029): NoteCard 이동 메뉴 (완료/보관/휴지통) + MoveStatusModal stub"
```

---

### Task 7: MoveStatusModal — 사유 입력 + 4 status 버튼

**Files:**

- Create: `src/renderer/inbox/components/MoveStatusModal.tsx`
- Create: `tests/unit/MoveStatusModal.test.tsx`
- Modify: `src/renderer/inbox/components/NoteCard.tsx` (stub → import)

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

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

const mockSetStatus = vi.fn(async () => ({ ok: true }));
vi.mock('../../src/renderer/inbox/api.js', () => ({
  inboxApi: {
    setStatus: mockSetStatus,
    classifyStatus: vi.fn(async () => ({ recommended: 'completed', rationale: '결재 끝' }))
  }
}));

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

describe('MoveStatusModal', () => {
  beforeEach(() => { vi.clearAllMocks(); cleanup(); });

  it('renders reason textarea + 4 status buttons + AI classify button', () => {
    render(
      <MoveStatusModal noteId="n1" rawText="t" summary="" initialTarget="completed"
        onClose={vi.fn()} onMoved={vi.fn()} />
    );
    expect(screen.getByRole('textbox')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /^완료$/ })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /^보관$/ })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /^휴지통$/ })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
  });

  it('clicking 완료 calls setStatus with reason', async () => {
    const onMoved = vi.fn();
    render(
      <MoveStatusModal noteId="n1" rawText="t" summary="" initialTarget="completed"
        onClose={vi.fn()} onMoved={onMoved} />
    );
    fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
    fireEvent.click(screen.getByRole('button', { name: /^완료$/ }));
    await vi.waitFor(() => {
      expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
      expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝');
    });
  });
});
```

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

- [ ] **Step 3: MoveStatusModal.tsx 작성**

```tsx
// src/renderer/inbox/components/MoveStatusModal.tsx
import React, { useState } from 'react';
import { inboxApi } from '../api.js';
import type { NoteStatus } from '@shared/types';

interface Props {
  noteId: string;
  rawText: string;
  summary: string;
  initialTarget: NoteStatus;
  onClose: () => void;
  onMoved: (status: NoteStatus, reason: string | null) => void;
}

export function MoveStatusModal({ noteId, rawText, summary, initialTarget, onClose, onMoved }: Props): React.ReactElement {
  const [reason, setReason] = useState('');
  const [recommendation, setRecommendation] = useState<{ status: NoteStatus; rationale: string } | null>(null);
  const [classifying, setClassifying] = useState(false);

  async function move(status: NoteStatus): Promise<void> {
    const trimmedReason = reason.trim() || null;
    await inboxApi.setStatus(noteId, status, trimmedReason);
    onMoved(status, trimmedReason);
  }

  async function classify(): Promise<void> {
    setClassifying(true);
    setRecommendation(null);
    const r = await inboxApi.classifyStatus(noteId, reason);
    setRecommendation({ status: r.recommended, rationale: r.rationale });
    setClassifying(false);
  }

  return (
    <div role="dialog" aria-label="이동" style={{
      position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
      background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100
    }}>
      <div style={{ background: '#fff', padding: 16, borderRadius: 8, minWidth: 400, maxWidth: 520 }}>
        <h2 style={{ fontSize: 16, margin: '0 0 12px' }}>메모 이동</h2>
        <textarea
          value={reason}
          onChange={e => setReason(e.target.value)}
          placeholder="이동 사유 (선택사항)"
          rows={2}
          style={{ width: '100%', padding: 6, fontSize: 13 }}
        />
        <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
          <button onClick={classify} disabled={classifying}>{classifying ? '분류 중...' : 'AI 자동 분류'}</button>
          <button onClick={() => move('completed')}>완료</button>
          <button onClick={() => move('archived')}>보관</button>
          <button onClick={() => move('trashed')}>휴지통</button>
          <button onClick={onClose} style={{ marginLeft: 'auto' }}>취소</button>
        </div>
        {recommendation && (
          <div style={{ marginTop: 12, padding: 8, background: '#f0f8ff', borderRadius: 4, fontSize: 12 }}>
            <div>AI 추천: <strong>{statusLabel(recommendation.status)}</strong></div>
            <div style={{ marginTop: 4 }}>이유: {recommendation.rationale}</div>
            <div style={{ marginTop: 8 }}>
              <button onClick={() => move(recommendation.status)}>확정 ({statusLabel(recommendation.status)})</button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

function statusLabel(s: NoteStatus): string {
  switch (s) {
    case 'completed': return '완료';
    case 'archived': return '보관';
    case 'trashed': return '휴지통';
    case 'active': return '활성';
  }
}
```

- [ ] **Step 4: NoteCard.tsx 의 stub 제거 + 정식 import**

NoteCard.tsx 의 임시 inline `MoveStatusModal` 제거 + 새 file import:

```tsx
import { MoveStatusModal } from './MoveStatusModal';
```

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

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

Expected: 신규 2 test + NoteCard 회귀 정상.

- [ ] **Step 6: commit**

```bash
git add -A
git commit -m "feat(v029): MoveStatusModal — 사유 입력 + 4 status 버튼 + AI 자동 분류 placeholder"
```

---

### Task 8: setStatus IPC + listByStatus 회귀

**Files:**

- Modify: `src/main/ipc/inboxApi.ts` (`inbox:set-status` 핸들러)
- Modify: `src/preload/index.ts`
- Modify: `src/shared/types.ts` (InboxApi.setStatus 시그니처)
- Test: `tests/unit/inboxApi-setStatus.test.ts`

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

```ts
// tests/unit/inboxApi-setStatus.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

const handlers: Record<string, Function> = {};
const mockSetStatus = vi.fn();
vi.mock('electron', () => ({
  default: { ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } } }
}));

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

  it('forwards id/status/reason to repo.setStatus', async () => {
    const { registerInboxApi } = await import('../../src/main/ipc/inboxApi');
    registerInboxApi({
      paths: { profileDir: '/p' },
      repo: { setStatus: mockSetStatus, listByStatus: vi.fn(() => []) },
      // 기타 deps stub
    } as any);
    const r = await handlers['inbox:set-status'](null, 'n1', 'completed', '결재 끝');
    expect(r).toEqual({ ok: true });
    expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
  });

  it('rejects invalid status value', async () => {
    const { registerInboxApi } = await import('../../src/main/ipc/inboxApi');
    registerInboxApi({ paths: { profileDir: '/p' }, repo: { setStatus: mockSetStatus } } as any);
    const r = await handlers['inbox:set-status'](null, 'n1', 'invalid', null);
    expect(r.ok).toBe(false);
  });
});
```

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

- [ ] **Step 3: 핸들러 추가**

```ts
// src/main/ipc/inboxApi.ts — registerInboxApi 안:
const VALID_STATUS = new Set<NoteStatus>(['active', 'completed', 'archived', 'trashed']);

ipcMain.handle('inbox:set-status', async (_e, id: string, status: NoteStatus, reason: string | null) => {
  if (!VALID_STATUS.has(status)) return { ok: false as const, reason: 'invalid status' as const };
  deps.repo.setStatus(id, status, reason);
  return { ok: true as const };
});

ipcMain.handle('inbox:list-by-status', async (_e, status: NoteStatus, opts: { limit?: number } = {}) => {
  if (!VALID_STATUS.has(status)) return [];
  return deps.repo.listByStatus(status, opts);
});
```

(Task 5 에서 `inbox:list-by-status` 가 이미 추가됐다면 중복 — 체크 후 한 곳만.)

- [ ] **Step 4: types/preload/api.ts 갱신**

```ts
// src/shared/types.ts
setStatus(id: string, status: NoteStatus, reason: string | null): Promise<{ ok: true } | { ok: false; reason: string }>;
listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise<Note[]>;
```

preload + api wildcard re-export.

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

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

- [ ] **Step 6: commit**

```bash
git add -A
git commit -m "feat(v029): inbox:set-status + inbox:list-by-status IPC + types"
```

---

## Phase 5: AI 자동 분류

### Task 9: AiWorker.classifyStatus + IPC

**Files:**

- Create: `src/main/ai/classifyStatus.ts`
- Modify: `src/main/ai/AiWorker.ts` (또는 별도 service)
- Modify: `src/main/ipc/inboxApi.ts`
- Test: `tests/unit/classifyStatus.test.ts`

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

```ts
// tests/unit/classifyStatus.test.ts
import { describe, it, expect, vi } from 'vitest';
import { classifyStatus } from '../../src/main/ai/classifyStatus';

describe('classifyStatus', () => {
  it('parses recommended status and rationale from AI response', async () => {
    const mockProvider = {
      generate: vi.fn(async () => ({
        rawText: '{"recommended":"completed","rationale":"처리됨 + 사용자 사유 결재 끝"}',
        title: '', summary: '', tags: []
      }))
    };
    const r = await classifyStatus({
      provider: mockProvider as any,
      rawText: 'X 미팅 결재',
      summary: '결재 처리',
      reason: '결재 끝'
    });
    expect(r.recommended).toBe('completed');
    expect(r.rationale).toContain('처리됨');
  });

  it('falls back to "trashed" + rationale on AI parse failure', async () => {
    const mockProvider = {
      generate: vi.fn(async () => ({ rawText: 'invalid json', title: '', summary: '', tags: [] }))
    };
    const r = await classifyStatus({
      provider: mockProvider as any,
      rawText: 't', summary: '', reason: 'r'
    });
    expect(r.recommended).toBe('archived');  // safe default — 사용자 데이터 보존
    expect(r.rationale).toMatch(/판단 실패/);
  });
});
```

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

- [ ] **Step 3: classifyStatus.ts 작성**

```ts
// src/main/ai/classifyStatus.ts
import type { InferenceProvider } from './InferenceProvider.js';
import type { NoteStatus } from '@shared/types';

interface Input {
  provider: InferenceProvider;
  rawText: string;
  summary: string;
  reason: string;
}

interface Output {
  recommended: NoteStatus;
  rationale: string;
}

const PROMPT_TEMPLATE = `다음 메모를 분류하세요.
가능한 status:
- completed: 작업이 끝났고 더 이상 행동 불필요
- archived: 장기 보관 — 회수 가능, 지금은 보지 않음
- trashed: 불필요, 의미 없는 메모

JSON 출력: { "recommended": "completed|archived|trashed", "rationale": "<한 문장>" }

메모 본문:
{{rawText}}

메모 요약:
{{summary}}

사용자 이동 사유:
{{reason}}`;

const VALID: NoteStatus[] = ['completed', 'archived', 'trashed'];

export async function classifyStatus(input: Input): Promise<Output> {
  const prompt = PROMPT_TEMPLATE
    .replace('{{rawText}}', input.rawText)
    .replace('{{summary}}', input.summary)
    .replace('{{reason}}', input.reason);

  try {
    const r = await input.provider.generate({
      text: prompt,
      todayKst: new Date().toISOString().slice(0, 10),
      dueDateCandidates: [],
      vocab: []
    } as any);
    const parsed = JSON.parse(r.rawText);
    if (typeof parsed.recommended !== 'string' || !VALID.includes(parsed.recommended)) {
      return { recommended: 'archived', rationale: '판단 실패 — 안전하게 보관 추천' };
    }
    return {
      recommended: parsed.recommended as NoteStatus,
      rationale: typeof parsed.rationale === 'string' ? parsed.rationale : ''
    };
  } catch {
    return { recommended: 'archived', rationale: '판단 실패 — 안전하게 보관 추천' };
  }
}
```

- [ ] **Step 4: IPC 핸들러 추가**

```ts
// src/main/ipc/inboxApi.ts
import { classifyStatus } from '../ai/classifyStatus.js';

ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => {
  const note = deps.repo.getById(id);
  if (!note) return { recommended: 'archived' as const, rationale: '메모 없음' };
  const provider = deps.providerHolder.get();
  return await classifyStatus({
    provider,
    rawText: note.rawText,
    summary: note.summary ?? '',
    reason
  });
});
```

types/preload/api wrapper:

```ts
classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>;
```

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

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

- [ ] **Step 6: commit**

```bash
git add -A
git commit -m "feat(v029): classifyStatus AI prompt + ai:classify-status IPC"
```

---

### Task 10: MoveStatusModal — AI 자동 분류 UI 통합

본 task 는 Task 7 + Task 9 산출물 결합. Task 7 의 `classify()` 함수가 Task 9 의 IPC 호출 — 이미 mock 가 정상 시그니처 가정. 본 task 는 회귀 검증 + UX 디테일 (loading state / error message) 마무리.

**Files:**

- Modify: `src/renderer/inbox/components/MoveStatusModal.tsx`
- Modify: `tests/unit/MoveStatusModal.test.tsx`

- [ ] **Step 1: failing test (AI 추천 + 확정)**

```tsx
it('shows AI recommendation + confirm flow', async () => {
  const onMoved = vi.fn();
  render(
    <MoveStatusModal noteId="n1" rawText="t" summary="" initialTarget="completed"
      onClose={vi.fn()} onMoved={onMoved} />
  );
  fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
  fireEvent.click(screen.getByRole('button', { name: /AI 자동 분류/ }));
  await screen.findByText(/AI 추천/);
  expect(screen.getByText(/완료/)).toBeInTheDocument();
  fireEvent.click(screen.getByRole('button', { name: /확정/ }));
  await vi.waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
});
```

- [ ] **Step 2: 기존 Task 7 코드 검증**

이미 Task 7 의 `MoveStatusModal` 가 recommendation state + 확정 버튼 보유. 동작 검증만.

- [ ] **Step 3: 회귀 + commit**

```bash
npx vitest run tests/unit/MoveStatusModal.test.tsx
git add -A
git commit -m "test(v029): MoveStatusModal AI 자동 분류 + 확정 회귀 추가" || echo "no changes"
```

---

## Phase 6: Onboarding wizard

### Task 11: settings 의 onboarding_completed flag + Wizard component

**Files:**

- Create: `src/renderer/inbox/components/OnboardingWizard.tsx`
- Modify: `src/renderer/inbox/App.tsx` (첫 launch 분기)
- Modify: `src/renderer/inbox/api.ts` (getSettings / setOnboardingCompleted / setAiEnabled wrapper)
- Modify: `src/main/ipc/settingsApi.ts` (해당 IPC 핸들러)
- Test: `tests/unit/OnboardingWizard.test.tsx`

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

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

const mockSetAi = vi.fn(async () => ({ ok: true }));
const mockSetCompleted = vi.fn(async () => ({ ok: true }));
vi.mock('../../src/renderer/inbox/api.js', () => ({
  inboxApi: { setAiEnabled: mockSetAi, setOnboardingCompleted: mockSetCompleted }
}));

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

describe('OnboardingWizard', () => {
  beforeEach(() => { vi.clearAllMocks(); cleanup(); });

  it('renders 3 options + 설치 가이드 link', () => {
    render(<OnboardingWizard onClose={vi.fn()} />);
    expect(screen.getByRole('button', { name: /AI 자동 처리 사용/ })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /원문만 저장/ })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /나중에 설정/ })).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /설치 가이드|ollama\.com/ })).toBeInTheDocument();
  });

  it('"AI 사용" → setAiEnabled(true) + setOnboardingCompleted(true) + onClose', async () => {
    const onClose = vi.fn();
    render(<OnboardingWizard onClose={onClose} />);
    fireEvent.click(screen.getByRole('button', { name: /AI 자동 처리 사용/ }));
    await vi.waitFor(() => {
      expect(mockSetAi).toHaveBeenCalledWith(true);
      expect(mockSetCompleted).toHaveBeenCalledWith(true);
      expect(onClose).toHaveBeenCalled();
    });
  });

  it('"원문만" → setAiEnabled(false) + setOnboardingCompleted(true)', async () => {
    render(<OnboardingWizard onClose={vi.fn()} />);
    fireEvent.click(screen.getByRole('button', { name: /원문만 저장/ }));
    await vi.waitFor(() => {
      expect(mockSetAi).toHaveBeenCalledWith(false);
      expect(mockSetCompleted).toHaveBeenCalledWith(true);
    });
  });
});
```

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

- [ ] **Step 3: OnboardingWizard.tsx 작성**

```tsx
// src/renderer/inbox/components/OnboardingWizard.tsx
import React from 'react';
import { inboxApi } from '../api.js';

export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement {
  async function choose(aiEnabled: boolean | null): Promise<void> {
    if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
    await inboxApi.setOnboardingCompleted(true);
    onClose();
  }

  return (
    <div role="dialog" aria-label="시작 안내" style={{
      position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
      background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
    }}>
      <div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 520 }}>
        <h2 style={{ margin: '0 0 12px' }}>Inkling 사용 시작</h2>
        <p style={{ fontSize: 14, lineHeight: 1.6, marginBottom: 12 }}>
          Inkling 은 로컬 LLM (Ollama) 으로 메모를 자동 정리합니다.
          Ollama 가 설치되어 있고 한국어 지원 모델 (gemma3, gemma2 등) 이 pull 되어 있어야 최적의 경험이 가능합니다.
        </p>
        <p style={{ fontSize: 13, marginBottom: 16 }}>
          설치 가이드:&nbsp;
          <a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">ollama.com/download</a>
        </p>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          <button onClick={() => choose(true)}>AI 자동 처리 사용 (Ollama 필요)</button>
          <button onClick={() => choose(false)}>원문만 저장 (AI 처리 안 함)</button>
          <button onClick={() => choose(null)} style={{ marginTop: 4 }}>나중에 설정</button>
        </div>
      </div>
    </div>
  );
}
```

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

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

- [ ] **Step 5: commit**

```bash
git add src/renderer/inbox/components/OnboardingWizard.tsx tests/unit/OnboardingWizard.test.tsx
git commit -m "feat(v029): OnboardingWizard 3 옵션 + 설치 가이드 link"
```

---

### Task 12: App.tsx 첫 launch 분기 + IPC + settings wrapper

**Files:**

- Modify: `src/renderer/inbox/App.tsx`
- Modify: `src/main/ipc/settingsApi.ts` (set-ai-enabled / set-onboarding-completed / get-settings)
- Modify: `src/preload/index.ts`
- Modify: `src/renderer/inbox/api.ts` (wrapper)
- Modify: `src/shared/types.ts` (시그니처)

- [ ] **Step 1: App.tsx 갱신**

```tsx
// App 안:
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);

useEffect(() => {
  void (async () => {
    const settings = await inboxApi.getSettings();
    setShowOnboarding(!settings.onboarding_completed);
  })();
}, []);

if (showOnboarding === null) return <></>;  // 또는 로딩
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;

// 이후 기존 inbox 흐름
```

- [ ] **Step 2: settingsApi 핸들러 + wrapper**

```ts
// src/main/ipc/settingsApi.ts
ipcMain.handle('settings:get', async () => deps.settings.getAll());
ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => {
  await deps.settings.set('ai_enabled', enabled);
  return { ok: true as const };
});
ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => {
  await deps.settings.set('onboarding_completed', completed);
  return { ok: true as const };
});
```

types.ts InboxApi:

```ts
getSettings(): Promise<Settings>;
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
```

preload + api wrapper.

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

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

- [ ] **Step 4: commit**

```bash
git add -A
git commit -m "feat(v029): App.tsx 첫 launch onboarding + settings:* IPC (ai-enabled / onboarding-completed)"
```

---

## Phase 7: AI off NoteCard fallback + Banner 비활성

### Task 13: NoteCard ai_status='disabled' fallback

**Files:**

- Modify: `src/renderer/inbox/components/NoteCard.tsx`
- Modify: `tests/unit/NoteCard.test.tsx`

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

```tsx
it('ai_status=disabled: title fallback to raw_text first line, summary/tags hidden', () => {
  const disabledNote = { ...baseNote, aiStatus: 'disabled', title: '', summary: 'should-not-show', tags: ['t1'], rawText: '첫 줄 본문\n둘째 줄 본문' };
  render(<NoteCard note={disabledNote} mode="inbox" onUpdated={vi.fn()} />);
  expect(screen.getByText('첫 줄 본문')).toBeInTheDocument();
  expect(screen.queryByText('should-not-show')).toBeNull();
  expect(screen.queryByText('t1')).toBeNull();
});
```

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

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

```tsx
// NoteCard 안:
const isDisabled = local.aiStatus === 'disabled';
const displayTitle = isDisabled
  ? (local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)')
  : (local.title?.trim() || local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)');

// title 표시:
<h3>{displayTitle}</h3>

// summary / tags 표시 — isDisabled 면 hide:
{!isDisabled && local.summary && <p>{local.summary}</p>}
{!isDisabled && local.tags.length > 0 && (
  <div>{local.tags.map(t => <span key={t}>{t}</span>)}</div>
)}
```

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

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

- [ ] **Step 5: commit**

```bash
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(v029): NoteCard ai_status='disabled' fallback (raw_text 첫 줄 + summary/tags hide)"
```

---

### Task 14: OllamaBanner / FailedBanner / HealthChecker ai_enabled false 시 비활성

**Files:**

- Modify: `src/renderer/inbox/components/OllamaBanner.tsx`
- Modify: `src/renderer/inbox/components/FailedBanner.tsx`
- Modify: `src/main/health/HealthChecker.ts`
- Modify: `src/renderer/inbox/store.ts` (settings load → state 에 ai_enabled 보관)
- Modify: `tests/unit/OllamaBanner.test.tsx` / `FailedBanner.test.tsx`

- [ ] **Step 1: failing test (Banner)**

```tsx
// tests/unit/OllamaBanner.test.tsx (또는 신규)
it('renders nothing when ai_enabled=false', () => {
  useInbox.setState({ ai_enabled: false, ollamaStatus: { ok: false, reason: 'unreachable' } });
  const { container } = render(<OllamaBanner />);
  expect(container).toBeEmptyDOMElement();
});

it('renders banner when ai_enabled=true and ollama not ok', () => {
  useInbox.setState({ ai_enabled: true, ollamaStatus: { ok: false, reason: 'unreachable' } });
  render(<OllamaBanner />);
  expect(screen.getByText(/Ollama/)).toBeInTheDocument();
});
```

(FailedBanner 동일 패턴)

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

- [ ] **Step 3: store + Banner 갱신**

store 에 `ai_enabled` field 추가:

```ts
// store.ts
ai_enabled: true,  // initial — settings 로드 시 갱신

async loadInitial() {
  const settings = await inboxApi.getSettings();
  set({ ai_enabled: settings.ai_enabled });
  // ... 기존
}
```

OllamaBanner / FailedBanner 갱신:

```tsx
// OllamaBanner.tsx
const aiEnabled = useInbox(s => s.ai_enabled);
const status = useInbox(s => s.ollamaStatus);
if (!aiEnabled) return null;
if (status.ok) return null;
// 기존 banner render
```

```tsx
// FailedBanner.tsx — 동일 패턴
const aiEnabled = useInbox(s => s.ai_enabled);
const failedCount = useInbox(s => s.failedCount);
if (!aiEnabled) return null;
if (failedCount === 0) return null;
// 기존 banner render
```

HealthChecker — ai_enabled=false 시 polling 중단:

```ts
// src/main/health/HealthChecker.ts
private async tick(): Promise<void> {
  const aiEnabled = await this.deps.settings.get('ai_enabled', true);
  if (!aiEnabled) return;  // skip
  // 기존 healthCheck 흐름
}
```

(deps 에 settings 추가 필요. main/index.ts wiring 갱신.)

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

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

- [ ] **Step 5: commit**

```bash
git add -A
git commit -m "feat(v029): Banner + HealthChecker ai_enabled=false 시 비활성"
```

---

## Phase 8: 설정 페이지 토글 + disabled 메모 처리

### Task 15: AiProviderSection 의 "AI 자동 처리 사용" 토글

**Files:**

- Modify: `src/renderer/inbox/components/settings/AiProviderSection.tsx`
- Modify: `tests/unit/AiProviderSection.test.tsx`

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

```tsx
it('renders AI 자동 처리 toggle (default true)', async () => {
  const { inboxApi } = await import('../../src/renderer/inbox/api.js');
  vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true, /* ... */ } as any);
  render(<AiProviderSection />);
  const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
  expect((toggle as HTMLInputElement).checked).toBe(true);
});

it('toggling calls setAiEnabled', async () => {
  const { inboxApi } = await import('../../src/renderer/inbox/api.js');
  vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as any);
  render(<AiProviderSection />);
  const toggle = await screen.findByLabelText(/AI 자동 처리 사용/);
  fireEvent.click(toggle);
  await vi.waitFor(() => expect(inboxApi.setAiEnabled).toHaveBeenCalledWith(false));
});
```

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

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

```tsx
// AiProviderSection.tsx 상단:
const [aiEnabled, setAiEnabled] = useState<boolean | null>(null);
const [disabledCount, setDisabledCount] = useState(0);

useEffect(() => {
  void (async () => {
    const s = await inboxApi.getSettings();
    setAiEnabled(s.ai_enabled);
    if (s.ai_enabled) {
      const c = await inboxApi.getDisabledCount();
      setDisabledCount(c);
    }
  })();
}, []);

async function onToggleAi(checked: boolean): Promise<void> {
  await inboxApi.setAiEnabled(checked);
  setAiEnabled(checked);
}

// JSX (기존 endpoint/model 위에):
{aiEnabled !== null && (
  <label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
    <input type="checkbox" checked={aiEnabled} onChange={e => onToggleAi(e.target.checked)} />
    AI 자동 처리 사용
  </label>
)}
{aiEnabled === false && (
  <p style={{ fontSize: 12, color: '#666', marginBottom: 12 }}>
    원문만 저장 모드. 메모의 제목/요약/태그가 자동 생성되지 않습니다.
    <br />
    <a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">Ollama 설치 가이드</a>
  </p>
)}
```

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

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

- [ ] **Step 5: commit**

```bash
git add src/renderer/inbox/components/settings/AiProviderSection.tsx tests/unit/AiProviderSection.test.tsx
git commit -m "feat(v029): AiProviderSection AI 자동 처리 토글 + disabled count"
```

---

### Task 16: ON 전환 후 "기존 disabled 메모 N건 처리" 버튼

**Files:**

- Modify: `src/renderer/inbox/components/settings/AiProviderSection.tsx`
- Modify: `src/main/ipc/inboxApi.ts` (`inbox:enqueue-disabled` IPC)
- Modify: `src/main/repository/NoteRepository.ts` (`requeueDisabled()` 메서드)
- Test: `tests/unit/NoteRepository.test.ts`

- [ ] **Step 1: failing test (repo)**

```ts
describe('NoteRepository.requeueDisabled', () => {
  it('changes ai_status="disabled" → "pending" + INSERT pending_jobs', () => {
    const id = repo.create({ rawText: 't', aiStatus: 'disabled', now: new Date() }).id;
    const count = repo.requeueDisabled();
    expect(count).toBe(1);
    const note = repo.getById(id);
    expect(note?.aiStatus).toBe('pending');
    const job = db.prepare(`SELECT * FROM pending_jobs WHERE note_id=?`).get(id);
    expect(job).toBeDefined();
  });
});
```

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

- [ ] **Step 3: NoteRepository.requeueDisabled 추가**

```ts
requeueDisabled(now: Date = new Date()): number {
  const tx = this.db.transaction(() => {
    const ts = now.toISOString();
    const targets = this.db.prepare(`SELECT id FROM notes WHERE ai_status='disabled'`).all() as Array<{ id: string }>;
    for (const { id } of targets) {
      this.db.prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`).run(ts, id);
      this.db.prepare(`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, ts);
    }
    return targets.length;
  });
  return tx();
}
```

- [ ] **Step 4: IPC 핸들러**

```ts
// src/main/ipc/inboxApi.ts
ipcMain.handle('inbox:enqueue-disabled', async () => {
  const count = deps.repo.requeueDisabled();
  // 큐에 들어갔으니 worker.notify
  if (count > 0) await deps.worker.notify();
  return { count };
});

ipcMain.handle('inbox:get-disabled-count', async () => {
  return deps.repo.countByAiStatus('disabled');
});
```

(NoteRepository 에 `countByAiStatus` 도 신규 — 1줄 추가.)

- [ ] **Step 5: AiProviderSection UI 갱신**

```tsx
// 토글 ON 일 때 + disabledCount > 0 일 때:
{aiEnabled === true && disabledCount > 0 && (
  <div style={{ padding: 8, background: '#fffbe5', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
    원문만 저장된 메모 {disabledCount}건이 있습니다.
    <button onClick={async () => {
      const r = await inboxApi.enqueueDisabled();
      setDisabledCount(0);
      // 사용자 알림 (toast 또는 inline)
    }} style={{ marginLeft: 8 }}>
      지금 모두 처리
    </button>
  </div>
)}
```

types/preload/api wrapper:

```ts
enqueueDisabled(): Promise<{ count: number }>;
getDisabledCount(): Promise<number>;
```

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

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

- [ ] **Step 7: commit**

```bash
git add -A
git commit -m "feat(v029): requeueDisabled + enqueue-disabled IPC + AiProviderSection 처리 버튼"
```

---

## Phase 9: verification + version bump

### Task 17: 회귀 + dogfood F17/F18/F23 promoted + version 0.2.9 bump

**Files:**

- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F17/F18/F23 promoted)
- Modify: `package.json` (version 0.2.8 → 0.2.9)

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

```bash
npm run rebuild:node
npm test 2>&1 | tail -5
npm run typecheck 2>&1 | tail -3
npm run rebuild:electron
npm run test:e2e 2>&1 | tail -10
```

Expected: 단위 472 → 약 510 (+38: m004 4 + repo 4 + capture 2 + view 3 + tabs 2 + Modal 2-3 + IPC 2-3 + classify 2 + Onboarding 3 + NoteCard fallback 1 + Banner 2-3 + AI 토글 2 + requeue 1 + 기타). typecheck 0. e2e 1/1.

- [ ] **Step 2: dogfood-feedback.md F17 entry 갱신**

```markdown
## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
```

진행 상태:

```markdown
**진행 상태:** 🚀 promoted → v0.2.9 Cut B. status 4분기 (active/completed/archived/trashed) + AI 자동 분류 버튼.
```

- [ ] **Step 3: F18 / F23 entry 갱신**

```markdown
## F18. 메모 휴지통/보관 이동 시 사유 입력 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
```

```markdown
## F23. 로컬 LLM 활성화 옵션 (Ollama-less 모드) (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
```

각 진행 상태도 `🚀 promoted → v0.2.9 Cut B` 로 갱신.

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

```json
"version": "0.2.9"
```

- [ ] **Step 5: commit**

```bash
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json
git commit -m "chore(release): v0.2.9 — Cut B (status 4분기 + 사유 + Ollama-less)"
```

---

## Self-Review

**Spec coverage:**

| Spec 섹션 | task |
|---|---|
| §3 F17 schema (m004 + status enum) | Task 1 |
| §3-2 NoteRepository setStatus + listByStatus | Task 2 |
| §3-3 4탭 UI + view enum | Task 4, 5 |
| §3-4 NoteCard 액션 메뉴 | Task 6 |
| §3-5 AI 자동 분류 prompt + UI | Task 9, 10 |
| §3-6 IPC inbox:set-status / ai:classify-status | Task 8, 9 |
| §4 F18 자유 텍스트 사유 (move_reason 컬럼) | Task 1 (schema) + Task 7 (UI) |
| §5-1 ai_status 'disabled' enum | Task 3 |
| §5-2 Onboarding wizard 3 옵션 + 설치 가이드 | Task 11, 12 |
| §5-3 CaptureService aiEnabled 분기 | Task 3 |
| §5-4 NoteCard fallback (title=raw_text 첫 줄) | Task 13 |
| §5-5 Banner 비활성 | Task 14 |
| §5-6 설정 페이지 토글 | Task 15 |
| §5-7 ON 전환 후 disabled 처리 | Task 16 |
| §6 테스트 472 → ~510 | 각 task |

모든 spec 요구가 task 매핑됨.

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

**Type consistency:**

- `NoteStatus = 'active' | 'completed' | 'archived' | 'trashed'` — Task 2, 8, 9 동일 사용
- `setStatus(id, status, reason)` 시그니처 — repo (Task 2), IPC (Task 8), MoveStatusModal (Task 7), App.tsx 일관
- `classifyStatus({recommended, rationale})` — Task 9 정의, Task 10 UI 사용 일관
- `ai_enabled`, `onboarding_completed` — settings schema (Task 3) + Wizard (Task 11) + Banner (Task 14) + AiProviderSection (Task 15) 일관
- `requeueDisabled(): number` — Task 16 정의, AiProviderSection UI 사용 일관

이슈 없음.

---

## Execution Handoff

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

두 가지 실행 옵션:

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

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

어느 쪽?
