# v0.3.2 — Cleanup Cut Design

**작성일:** 2026-05-10
**선행 문서:**

- `docs/superpowers/v024-backlog.md` (잔여 backlog audit)
- `~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` (stale memory — 본 cut 에서 폐기)
- `docs/superpowers/strategy/v028plus-roadmap.md`

**Cut 라벨:** v0.3.2 — patch (기능 추가 X, 잠재 bug fix + cosmetic + 기록 정리)

---

## 1. Cut 정체성

기능 추가 X. backlog 잔여 23건 중 **잠재 bug 4건 + cosmetic 6건 + 기록 정리 2건 = 12건** 일괄 처리. data-dependent 항목 (telemetry 분포 의존) 과 cross-cutting refactor (TrayController class / Banner CSS variables) 는 dogfood 후 재평가. Cut F (v0.3.1) + Cut E (v0.3.0) 종합 dogfood ≥1주 soak 진입을 위한 baseline 정리.

---

## 2. 범위

| 항목 | 결정 |
|---|---|
| **포함 카테고리** | 잠재 bug + cosmetic + 기록 정리 |
| **보류 카테고리** | data-dependent (9건) + cross-cutting refactor (4건) |
| **테스트 +** | 단위 710 → 약 720 (+10), typecheck 0 |
| **schema 변경** | 없음 (m007 이후) |
| **단일 PR** | v0.3.2 — 12 항목 = 7~8 commit (카테고리별 묶음) |

---

## 3. 잠재 bug fix (4건)

### 3-1. `vocabSet` COLLATE NOCASE 정합 (#31)

**현재 코드** (`src/main/ai/AiWorker.ts` `processJob`):

```ts
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
const vocabSet = new Set(vocab);
// ...
if (vocabSet.has(tagName)) { ... }  // strict-eq, DB COLLATE NOCASE 와 충돌 가능
```

**문제**: vocab pool 확장 시 (사용자가 `'Design'` 같은 capital case 추가하면) `getTagIdByName('Design')` 은 COLLATE NOCASE 로 매치되지만 `vocabSet.has('Design')` strict-eq 는 lowercase 만 등록된 set 에 miss → tagId 있는데 vocab hit 0 → silently skip.

**수정**:

```ts
const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N);
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
// ...
if (vocabSet.has(tagName.toLowerCase())) { ... }
```

**테스트 (3건 신규)**:

- 대문자 vocab + lowercase AI tag → hit
- lowercase vocab + 대문자 AI tag → hit
- 동일 lowercase → hit (회귀)

### 3-2. Time-dependent test flake fix

**문제**: `NoteRevisions.test.ts` 의 v1 capture 가 `repo.create()` 호출 → `NoteRepository.create` 가 `new Date()` (NOW) 로 `created_at` / `edited_at` 박음. v2 는 fixed `2026-05-10T00:00:00Z` 명시 주입. 시스템 시계가 `2026-05-10T00:00:00Z` 초과 시 v1.edited_at > v2.edited_at → DESC ordering 깨짐. `upsertFromSync.test.ts` 도 동일 패턴.

**수정**: `NoteRepository.create(input, now?: Date)` 추가 (기존 `setStatus(id, status, reason, now: Date)` / `updateRawText(id, text, now: Date)` 패턴 정합).

```ts
// src/main/repository/NoteRepository.ts
create(input: CreateNoteInput, now: Date = new Date()): Note {
  const ts = now.toISOString();
  // 기존 INSERT 의 createdAt / updatedAt / edited_at 모두 ts 사용
  // ...
}
```

**기존 호출자 무영향** — production 코드는 `now` 생략 → 기본 `new Date()` 동일 동작.

**테스트 (5건 회복 + 2건 신규)**:

- `NoteRevisions.test.ts` 의 4 testcase v1 capture 도 fixed 시간 (`'2026-05-09T00:00:00Z'`) 주입 → v2 (`'2026-05-10T00:00:00Z'`) 이전 보장
- `upsertFromSync.test.ts` 의 v1 capture 도 fixed 시간 주입
- `NoteRepository.create` default 시간 (`now` 생략 시 `new Date()`) 단위 1
- `NoteRepository.create` 명시 주입 단위 1

### 3-3. PII reason 마스킹 (#39)

**현재 코드** (`src/main/ai/LocalOllamaProvider.ts` `healthCheck`):

```ts
} catch (e) {
  this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable: ${(e as Error).message}` } });
}
```

**문제**: `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능 → telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN 사용 흔하게 만들어 노출 경로 확대.

**수정**: error class 분류 + host 마스킹.

```ts
function classifyFetchError(e: unknown): 'timeout' | 'network' | 'dns' | 'other' {
  const msg = (e as Error).message?.toLowerCase() ?? '';
  if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
  if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
  if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
  return 'other';
}

// emit
const reason = classifyFetchError(e);
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason } });
```

`AiFailedReason` zod enum (`'unreachable' | 'schema' | 'timeout' | 'other'`) 와 별개 — `ollama_unreachable.payload.reason` 만 신규 enum 도입 또는 기존 union 확장. spec 단계 결정: **기존 `'unreachable'` 그대로 유지**, 신규 enum 추가 X (단순화). reason 변환 후 prefix 만 변경:

```ts
const cls = classifyFetchError(e);
this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable:${cls}` } });
```

**테스트 (4건 신규)**:

- `ECONNREFUSED` → `unreachable:network`
- `ETIMEDOUT` → `unreachable:timeout`
- `ENOTFOUND` → `unreachable:dns`
- 그 외 → `unreachable:other`

### 3-4. KST_OFFSET_MS inline duplication 5 callsite → import (#19)

**현재**: canonical `src/shared/util/kstDate.ts` 가 있는데 5 callsite inline duplicate.

| 파일 | 라인 | 처리 |
|---|---|---|
| `src/main/repository/NoteRepository.ts:1042` | inline `const KST_OFFSET_MS = ...` | `import { KST_OFFSET_MS } from '@shared/util/kstDate.js'` |
| `src/main/repository/ftsHelpers.ts:18` | 동 | 동 |
| `src/main/services/BackupService.ts:6` | 동 | 동 |
| `src/main/services/ContinuityService.ts:4` | 동 | 동 |
| `src/renderer/inbox/components/NoteCard.tsx:30` | 동 | 동 (renderer alias 경계 X — `@shared/...` 양쪽 import 가능) |

**테스트**: 단위 추가 없음 — 기존 회귀 검사. `kstDate.ts` 의 export 가 동일 값 (9 \* 60 \* 60 \* 1000) 이므로 동작 무변화.

---

## 4. Cosmetic / readability (6건)

### 4-1. 탭 ARIA 패턴 정정 (#14)

`aria-pressed` 는 toggle 버튼용. 본 UI 의 탭 (Inbox / 휴지통 / 회고 등) 은 `role="tab"` + `aria-selected` canonical. screen reader 동작 OK 였지만 a11y audit canonical 정정.

**파일**: `src/renderer/inbox/App.tsx` (탭 컨테이너) — grep 으로 `aria-pressed` 위치 확정 후 수정.

**테스트**: 단위 추가 없음 (기존 RTL 단위가 selectable 검증). 단 `aria-selected="true"` 관련 assertion 1건 추가 검증.

### 4-2. `loadExpired()` 미사용 제거 (#18)

`store.ts` 의 `loadExpired()` action 이 `loadInitial`/`refreshMeta` 가 inline fetch 하면서 사용 안 함. App.tsx 호출 0건. test 만 exercise.

**처리**: dead-code 제거. 관련 test 도 제거.

### 4-3. AiWorker per-tag emit `Promise.all` 병렬화 (#32)

**현재**:

```ts
for (const tag of new Set(...)) {
  await this.telemetry.emit({ kind: 'tag_vocab_hit' or 'miss', ... });  // serial
}
```

**수정**:

```ts
await Promise.all(
  Array.from(new Set(...)).map((tag) => this.telemetry.emit({ ... }))
);
```

**Risk**: emit 순서 변경 — telemetry 파일 라인 순서 의존 단위 없음 확인. `ai_succeeded` emit 도 serial 이지만 본 cut 은 **per-tag emit 만** 변경 (tag_vocab_hit / tag_vocab_miss). `ai_succeeded` 는 serial 유지 (per-tag 와 다른 호출 시점).

**테스트**: 회귀 단위 PASS 확인. 신규 단위 추가 없음 (병렬 동작 자체 검증은 unit 무리).

### 4-4. `emitRecallShown` / `emitRecallSnoozed` `ipcMain.handle → on` (#36)

`fire-and-forget` 정책 호출 측 (RecallBanner) → return value 사용 안 함. canonical pattern: `ipcMain.on` (return value 없음).

**파일**: `src/main/ipc/inboxApi.ts` 또는 telemetry IPC 정의 위치. `emitRecallShown` / `emitRecallSnoozed` 만 `handle → on` migration. 호출 측 (`window.api.emitRecallShown` 등) 의 `Promise<void>` 시그니처 그대로 (preload 가 `ipcRenderer.send`).

**Risk**: `ipcMain.handle` 의 return value 의존 호출자 grep 으로 0 확인.

**테스트 (1건 신규)**: `vision-ipc.test.ts` 패턴 정합 — `ipcRenderer.send` 호출 검증.

### 4-5. OllamaSettingsModal 폐기 확인 (#41+#42)

**현재 audit**: `grep "OllamaSettingsModal" src/` → 0건. v0.2.7 cut 에서 이미 폐기됨 (memory: "OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup").

**처리**: backlog 항목 닫기만. **코드 변경 0**. v024-backlog.md 의 #41/#42 처리 이력 ✅ 추가.

### 4-6. Telemetry `.catch(() => {})` silent → debug log (#20)

**현재**: `CaptureService.listExpired` / `trashExpiredBatch` 가 `.catch(() => {})` 로 silent.

**수정**:

```ts
this.telemetry.emit(...).catch((e) => {
  this.logger.debug('telemetry.emit.failed', { reason: String(e) });
});
```

`logger.debug` (project pattern) — production noise 0, 디버그 시 reproduce 가능.

**테스트**: 단위 추가 없음. 회귀 PASS.

---

## 5. 기록 정리 (2건)

### 5-1. v0.2.2 stale memory 폐기

`~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` — 6건 모두 v0.2.3~v0.2.9 cut 들에서 처리됨 (Ollama 회복 / AI 영속 큐 / 태그 vocab / 휴지통 / 만료 추천 / RecallBanner). 8일 stale.

**처리**:

- 파일 삭제
- `MEMORY.md` 의 line 8 (`- [v0.2.2 feedback]...`) 제거

### 5-2. v024-backlog.md 갱신

처리 이력 table 에 신규 12건 entry 추가:

```markdown
| #14 (탭 ARIA) | ✅ 처리 | v0.3.2 |
| #18 (loadExpired 미사용) | ✅ 처리 | v0.3.2 |
| #19 (KST inline 5 callsite 잔여) | ✅ 처리 | v0.3.2 |
| #20 (.catch silent → debug log) | ✅ 처리 | v0.3.2 |
| #31 (vocabSet COLLATE) | ✅ 처리 | v0.3.2 |
| #32 (per-tag Promise.all) | ✅ 처리 | v0.3.2 |
| #36 (recall IPC handle→on) | ✅ 처리 | v0.3.2 |
| #39 (PII reason 마스킹) | ✅ 처리 | v0.3.2 |
| #41+#42 (OllamaSettingsModal 폐기) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
| time-dependent test flake | ✅ 처리 | v0.3.2 |
```

총 처리 22 → 32, 잔여 23 → 11 (data-dependent 9 + future-proof 2).

---

## 6. 보류 항목 (dogfood 후 재평가)

### data-dependent (9건)

- #25 HealthChecker `inFlight` manual emit ordering — dogfood soak 결과 결정 (1초 윈도우 dedup 필요 여부)
- #29 `getTopUsedTags(20)` magic number → `VOCAB_TOP_N` 모듈 상수 (이미 #29 처리, 튜닝 자체는 telemetry 후)
- #30 `getTopUsedTags` SQL-side 필터 (overfetch+slice vs `GLOB`) — vocab pool 확장 결정
- #33 `PROMPT_VERSION` telemetry payload 추가 — prompt 튜닝 후 hit-rate 추적 시
- #35 `recall_shown` per-banner-lifetime dedup — telemetry 빈도 보고
- #40 Settings 저장 vs HealthChecker race — visible 빈도 확인
- #16 per-note 영구 삭제 telemetry 빈도 — 거의 0 이면 bulk emptyTrash 만 (UX 단순화)
- #28 `unreachableBackoffStep` job-level — multi-provider 도입 시
- recall_shown lifetime 영속 마커 (data-dependent #35 와 일부 중복)

### future-proof / cross-cutting (4건)

- #27 `refreshTrayFailedCount` exported singleton → TrayController class — multi-window 가설 검증 X
- #24+#41 Banner CSS variables — modal 폐기로 일부 자연 소멸 + 잔여 4 banner 의 hardcode 색상은 단일 dogfood UX 영향 0
- #37 NoteCard `id="note-${id}"` ref-forwarding — search 결과 scroll 등 신규 surface 등장 시
- #28 단일 카운터 vs job-level — multi-provider 진입 시점

---

## 7. 테스트 전략

| 영역 | 단위 |
|---|---|
| `vocabSet` lowercase normalize | 3 (대/소문자 vocab × AI tag matrix) |
| `NoteRepository.create(now)` param | 2 (default / 명시 주입) |
| `NoteRevisions.test.ts` flake fix | 4 testcase 회복 (v1 capture 시간 주입) |
| `upsertFromSync.test.ts` flake fix | 2 testcase 회복 |
| PII reason classification | 4 (network/timeout/dns/other) |
| KST inline → import migration | 회귀 PASS 확인 (단위 +0) |
| 탭 ARIA `aria-selected` | 1 신규 assertion |
| `loadExpired` 제거 | 회귀 (test 같이 제거) |
| AiWorker `Promise.all` 회귀 | 회귀 PASS |
| recall IPC `on` migration | 1 신규 (`ipcRenderer.send` 검증) |
| Telemetry `.catch` debug log | 회귀 PASS |

목표: 단위 710 → **약 720** (+10 신규, -2 제거 [`loadExpired` test], net +8). typecheck 0.

---

## 8. Risk

| Risk | 대응 |
|---|---|
| `NoteRepository.create(now)` signature 변경 호출자 영향 | optional + default = `new Date()`. 기존 호출자 0 무영향. typecheck 가 누락 catch |
| KST inline → import 회귀 (값 다른 정의) | canonical export 값 (9 \* 60 \* 60 \* 1000) 동일 검증. 기존 단위 회귀 PASS = 알고리즘 동일 |
| `ipcMain.handle → on` migration 시 return value 의존 호출자 누락 | grep 으로 호출자 enumerated. preload 시그니처 그대로 (`Promise<void>`) — 호출 측 무수정 |
| AiWorker `Promise.all` 으로 emit 순서 변경 | telemetry 파일 라인 순서 의존 단위 0 확인. file-append round-trip 만 줄어듦 |
| PII 마스킹으로 디버그 어려움 | error class enum + production reproduce 시 dev 환경에서 stack 그대로 노출 (telemetry 만 마스킹) |
| time-dependent fix 시 다른 시간 의존 단위 발견 | grep `new Date()` in `repo` methods → `create` 외 다른 메서드 audit. 발견 시 spec 갱신 X (cleanup cut 외 deferred) |

---

## 9. v0.3.2 후

**Cut G** (v0.3.3 또는 v0.4.0 — F25 사이드바 + notebook_id) 진입 전:

- v0.3.2 release → main → tag → Windows exe + Gitea release
- macOS host 핸드오프: dist:mac dmg + dist:linux AppImage/deb (v0.3.0 + v0.3.1 + v0.3.2 누적 backlog)
- **다기기 종합 dogfood ≥1주 soak**:
  - sync (Cut E): 충돌 빈도 / 인증 흐름 / interval 적정성 / NTP 단조 가정
  - vision (Cut F): 이미지 capture 빈도 / 한국어 정확도 / capability detection 정확도
  - cleanup (Cut 본 v0.3.2): 잠재 bug 회귀 X 확인
- soak 후 신규 발견 + data-dependent 9건 일괄 triage → Cut G brainstorm 진입

**Cut G 가설** (재검증):

1. inbox 단일 view 의 정보 밀도 한계 (현재 노트 누적 N건 = 스크롤 부담)
2. notebook 카테고리 = 분류 hint vs 단일 inbox + 태그 필터로 충분
3. 사이드바 = 새 surface = 기존 정책 (트레이 deemphasis / SettingsPage 우선) 재고
