Files
inkling/docs/superpowers/plans/2026-05-09-v029-cut-b.md
altair823 07e61bc9e1 docs(plan): v0.2.9 Cut B implementation plan
17 task / 9 phase:
- Phase 1 (T1-2): m004 schema (status/status_changed_at/move_reason) + NoteRepository.setStatus/listByStatus + restoreNote 재구현
- Phase 2 (T3): ai_status 'disabled' enum + CaptureService aiEnabled 분기 (skip pending_jobs)
- Phase 3 (T4-5): useInbox view enum 4탭 + 헤더 4탭 UI + listByStatus IPC
- Phase 4 (T6-8): NoteCard 액션 메뉴 + MoveStatusModal (사유 입력 + 4 status 버튼) + setStatus IPC
- Phase 5 (T9-10): classifyStatus AI prompt + ai:classify-status IPC + AI 추천 UI
- Phase 6 (T11-12): OnboardingWizard 3 옵션 + 설치 가이드 + App.tsx 첫 launch 분기
- Phase 7 (T13-14): NoteCard ai_status='disabled' fallback (raw_text 첫 줄) + Banner ai_enabled=false 비활성 + HealthChecker polling 중단
- Phase 8 (T15-16): AiProviderSection AI 자동 처리 토글 + requeueDisabled (ON 전환 후 처리 버튼)
- Phase 9 (T17): 회귀 + dogfood F17/F18/F23 promoted + version 0.2.9 bump

선행 spec: 2026-05-09-v029-cut-b-design.md.
단위 472 → 약 510 (+38) 목표.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:59:00 +09:00

61 KiB

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


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) 메서드 추가, 기존 restoreNotesetStatus(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

// 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
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 작성
// 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: 테스트 통과 확인
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 + 전체 회귀
npm run typecheck
npx vitest run

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

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

// 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
npx vitest run tests/unit/NoteRepository.test.ts

Expected: setStatus / listByStatus 미존재 → fail.

  • Step 3: types.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 메서드 추가
// 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) 로 교체:

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 매핑 추가:

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 + 회귀
npx vitest run tests/unit/NoteRepository.test.ts
npm run typecheck
npx vitest run

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

  • Step 6: commit
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.tsai_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 위치 참조):

// 기존 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 갱신
// 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 분기)
// 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
npx vitest run tests/unit/CaptureService.test.ts
  • Step 5: CaptureService 갱신
// 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 으로 받도록 갱신:

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 + 회귀
npx vitest run
npm run typecheck

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

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

// 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' 로 통합:

// 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 / showSettingstoggleShowTrash / 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 들 점진적 갱신.

// 임시 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
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

// 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 유지.

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 갱신
// 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 핸들러 추가
// 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 갱신:

listByStatus(status: NoteStatus, opts: { limit?: number }): Promise<Note[]>;
  • Step 6: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck

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

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

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

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 검사.

// 임시 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
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

// 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 작성

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

import { MoveStatusModal } from './MoveStatusModal';
  • Step 5: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck

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

  • Step 6: commit
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

// 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: 핸들러 추가

// 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 갱신
// 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 + 회귀
npx vitest run
npm run typecheck
  • Step 6: commit
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

// 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 작성

// 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 핸들러 추가
// 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:

classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>;
  • Step 5: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
  • Step 6: commit
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 추천 + 확정)

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

// 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 작성

// 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
npx vitest run tests/unit/OnboardingWizard.test.tsx
npm run typecheck
  • Step 5: commit
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 갱신

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

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

preload + api wrapper.

  • Step 3: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
  • Step 4: commit
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

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 갱신

// 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 + 회귀
npx vitest run
npm run typecheck
  • Step 5: commit
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)

// 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 추가:

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

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

OllamaBanner / FailedBanner 갱신:

// 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
// 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 중단:

// 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 + 회귀
npx vitest run
npm run typecheck
  • Step 5: commit
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

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 갱신

// 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 + 회귀
npx vitest run
npm run typecheck
  • Step 5: commit
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)

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 추가

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 핸들러
// 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 갱신
// 토글 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:

enqueueDisabled(): Promise<{ count: number }>;
getDisabledCount(): Promise<number>;
  • Step 6: 테스트 + typecheck + 회귀
npx vitest run
npm run typecheck
  • Step 7: commit
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 일괄

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 갱신
## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)

진행 상태:

**진행 상태:** 🚀 promoted → v0.2.9 Cut B. status 4분기 (active/completed/archived/trashed) + AI 자동 분류 버튼.
  • Step 3: F18 / F23 entry 갱신
## F18. 메모 휴지통/보관 이동 시 사유 입력 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md)
## 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
"version": "0.2.9"
  • Step 5: commit
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

어느 쪽?