# v0.2.3 #3 태그 vocab Implementation Plan

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

**Goal:** AI prompt 에 자주 쓰인 태그 vocabulary 를 주입해 재사용 유도하고, 태그별 hit/miss telemetry 로 효과 측정.

**Architecture:** `repo.getTopUsedTags(20)` SQL → `provider.generate(input + vocab)` → `buildPrompt` 가 vocab 라인 추가 → AI 응답 → `repo.updateAiResult` 후 per-tag hit/miss emit. 매 generate 마다 SQL fetch (캐싱 X). vocab 빈 케이스 시 prompt 라인 자체 생략.

**Tech Stack:** better-sqlite3 12.9, zod 4.3.6, vitest 4, undici MockAgent (LocalOllamaProvider 테스트), TypeScript strict. PROMPT_VERSION 3 → 4.

**Spec:** `docs/superpowers/specs/2026-05-02-v023-tag-vocab-design.md`

---

## File Structure

| File | Role | Action |
|------|------|--------|
| `src/main/repository/NoteRepository.ts` | DB layer | +`getTopUsedTags(limit)` +`getTagIdByName(name)` |
| `src/main/ai/prompt.ts` | LLM prompt | bump PROMPT_VERSION 3→4, +`vocab` 4th param, +vocabBlock |
| `src/main/ai/InferenceProvider.ts` | Provider contract | `GenerateInput.vocab?: string[]` |
| `src/main/ai/LocalOllamaProvider.ts` | Ollama impl | `generate()` 에서 `input.vocab ?? []` 를 buildPrompt 에 전달 |
| `src/main/ai/AiWorker.ts` | Job loop | vocab fetch 후 generate 호출, response 후 per-tag hit/miss emit, AiTelemetryEmitter union 확장 |
| `src/main/services/telemetryEvents.ts` | zod schema | +`TagVocabHitPayload` +`TagVocabMissPayload` 13→15 union |
| `src/main/services/telemetryStats.ts` | 누적 통계 | DailyRow +2 cols, accumulators, summary line "태그 vocab" |
| `src/main/services/TelemetryService.ts` | EmitInput type | union 13→15 |
| `tests/unit/NoteRepository.test.ts` | 테스트 | +7 cases |
| `tests/unit/prompt.test.ts` | 테스트 (신규) | +4 cases |
| `tests/unit/AiWorker.test.ts` | 테스트 | +4 cases |
| `tests/unit/telemetryEvents.test.ts` | 테스트 | +3 cases |
| `tests/unit/telemetryStats.test.ts` | 테스트 | +1 case |
| `tests/unit/TelemetryService.test.ts` | 테스트 | narrowing guards 확장 |

총 신규 단위 19개 + narrowing guard widening = 단위 363 → **382 (±5)**.

---

## Task 1: NoteRepository — getTopUsedTags + getTagIdByName

**Files:**
- Modify: `src/main/repository/NoteRepository.ts` (add 2 methods after `setNextRunAt`, around line 217)
- Test: `tests/unit/NoteRepository.test.ts` (append at end of `describe('NoteRepository')`)

- [ ] **Step 1: Write failing test — getTopUsedTags 빈 db**

```typescript
// in tests/unit/NoteRepository.test.ts (append)
it('getTopUsedTags returns [] when no notes', () => {
  expect(repo.getTopUsedTags()).toEqual([]);
});
```

- [ ] **Step 2: Run test to verify fail**

Run: `npm test -- NoteRepository`
Expected: FAIL with `repo.getTopUsedTags is not a function`

- [ ] **Step 3: Implement getTopUsedTags + getTagIdByName**

Add after `setNextRunAt(...)` method (around line 217):

```typescript
  /**
   * v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N.
   * source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외).
   * deleted_at IS NULL 만 (휴지통 노트 태그 제외).
   */
  getTopUsedTags(limit = 20): string[] {
    const rows = this.db
      .prepare(
        `SELECT t.name, COUNT(*) c
           FROM tags t
           JOIN note_tags nt ON nt.tag_id = t.id
           JOIN notes n ON n.id = nt.note_id
          WHERE n.deleted_at IS NULL
          GROUP BY t.id
          ORDER BY c DESC, t.id ASC
          LIMIT ?`
      )
      .all(limit) as Array<{ name: string; c: number }>;
    return rows
      .map((r) => r.name)
      .filter((n) => /^[a-z0-9-]+$/.test(n));
  }

  /**
   * v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장.
   * tags.name COLLATE NOCASE 라 case-insensitive lookup.
   */
  getTagIdByName(name: string): number | null {
    const row = this.db
      .prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`)
      .get(name) as { id: number } | undefined;
    return row ? row.id : null;
  }
```

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

Run: `npm test -- NoteRepository`
Expected: PASS

- [ ] **Step 5: Add 6 more cases**

Append to `tests/unit/NoteRepository.test.ts`:

```typescript
it('getTopUsedTags orders by count desc, id asc tiebreaker', () => {
  const a = repo.create({ rawText: 'a' }).id;
  const b = repo.create({ rawText: 'b' }).id;
  const c = repo.create({ rawText: 'c' }).id;
  repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' });
  repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
  repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });
  // counts: design=3, meeting=2, qa=1
  expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']);
});

it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => {
  const a = repo.create({ rawText: 'a' }).id;
  // user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증
  repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] });
  expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout']));
  expect(repo.getTopUsedTags()).not.toContain('회의');
  expect(repo.getTopUsedTags()).not.toContain('Meeting');
  expect(repo.getTopUsedTags()).not.toContain('two words');
});

it('getTopUsedTags counts AI + user sources together', () => {
  const a = repo.create({ rawText: 'a' }).id;
  const b = repo.create({ rawText: 'b' }).id;
  repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' });
  repo.updateUserAiFields(b, { tags: ['design'] });
  // design count 는 AI 1 + user 1 = 2
  const top = repo.getTopUsedTags();
  expect(top).toContain('design');
});

it('getTopUsedTags excludes tags from deleted notes', () => {
  const a = repo.create({ rawText: 'a' }).id;
  repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' });
  repo.softDelete(a);
  expect(repo.getTopUsedTags()).not.toContain('lonely');
});

it('getTopUsedTags respects LIMIT parameter', () => {
  const ids: string[] = [];
  for (let i = 0; i < 5; i++) {
    const id = repo.create({ rawText: `n${i}` }).id;
    ids.push(id);
    repo.updateAiResult(id, {
      title: 't', summary: 'a\nb\nc',
      tags: [`tag-${i}`],
      provider: 'p'
    });
  }
  expect(repo.getTopUsedTags(3)).toHaveLength(3);
  expect(repo.getTopUsedTags(10)).toHaveLength(5);
});

it('getTagIdByName returns id when present, null when absent', () => {
  const a = repo.create({ rawText: 'a' }).id;
  repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' });
  const id = repo.getTagIdByName('hello');
  expect(typeof id).toBe('number');
  expect(id).toBeGreaterThan(0);
  // case-insensitive
  expect(repo.getTagIdByName('HELLO')).toBe(id);
  // absent
  expect(repo.getTagIdByName('nothere')).toBeNull();
});
```

- [ ] **Step 6: Run all tests, verify pass**

Run: `npm test -- NoteRepository`
Expected: PASS — 7 new cases. Pre-existing cases unchanged.

- [ ] **Step 7: Commit**

```bash
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "$(cat <<'EOF'
feat(tag-vocab): NoteRepository — getTopUsedTags + getTagIdByName (#3 v0.2.3)

- getTopUsedTags(limit=20): top-N (count desc, id asc) + kebab-case 필터 + deleted_at 제외
- getTagIdByName(name): COLLATE NOCASE lookup
- AI+user source 통합 카운트 (Q1=C 결정)
- 단위 +7 cases (정렬, 필터, source 통합, deleted 제외, limit, getTagIdByName)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: prompt.ts — PROMPT_VERSION 4 + vocab parameter

**Files:**
- Modify: `src/main/ai/prompt.ts` (entire file)
- Create: `tests/unit/prompt.test.ts`

- [ ] **Step 1: Write failing test for new test file**

Create `tests/unit/prompt.test.ts`:

```typescript
import { describe, it, expect } from 'vitest';
import { buildPrompt, PROMPT_VERSION } from '@main/ai/prompt.js';

describe('prompt', () => {
  it('PROMPT_VERSION is 4', () => {
    expect(PROMPT_VERSION).toBe(4);
  });

  it('buildPrompt with empty vocab omits vocabulary line entirely', () => {
    const out = buildPrompt('hello', '2026-05-02', [], []);
    expect(out).not.toContain('vocabulary');
    expect(out).not.toContain('Prefer reusing');
  });

  it('buildPrompt with vocab includes Prefer instruction + comma-separated list', () => {
    const out = buildPrompt('hello', '2026-05-02', [], ['design', 'meeting', 'qa']);
    expect(out).toContain('Existing vocabulary tags');
    expect(out).toContain('design, meeting, qa');
    expect(out).toContain('Prefer reusing');
  });

  it('vocab block appears between candidate block and JSON rules', () => {
    const out = buildPrompt('hello', '2026-05-02', [], ['design']);
    const candidateIdx = out.indexOf('Today\'s date');
    const vocabIdx = out.indexOf('Existing vocabulary');
    const jsonRulesIdx = out.indexOf('Return a JSON object');
    expect(candidateIdx).toBeGreaterThan(-1);
    expect(vocabIdx).toBeGreaterThan(candidateIdx);
    expect(jsonRulesIdx).toBeGreaterThan(vocabIdx);
  });
});
```

- [ ] **Step 2: Run test to verify fail**

Run: `npm test -- prompt`
Expected: FAIL — `PROMPT_VERSION` is 3, `buildPrompt` only takes 3 args.

- [ ] **Step 3: Modify prompt.ts**

Replace entire file:

```typescript
import type { ParseResult } from '../services/dueDateParser.js';

export const PROMPT_VERSION = 4;

export function buildPrompt(
  rawText: string,
  todayKst: string,
  candidates: ParseResult[] = [],
  vocab: string[] = []
): string {
  const candidateBlock = candidates.length > 0
    ? `\nDate candidates extracted by a Korean rule parser (these are HINTS — you decide which is correct, or pick null):
${candidates.map((c, i) => `  ${i + 1}. ${c.iso ?? '(ambiguous)'} — matched token: "${c.matchedToken ?? '?'}" (confidence: ${c.confidence ?? 'low'})`).join('\n')}\n`
    : '';

  const vocabBlock = vocab.length > 0
    ? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n`
    : '';

  return `You organize raw personal notes into structured metadata.

Today's date in Korea Standard Time (KST): ${todayKst}
${candidateBlock}${vocabBlock}
Input note (raw text, may be fragmented, any language):
---
${rawText}
---

Return a JSON object with EXACTLY these keys:
- "title": concise title in KOREAN (max 60 chars)
- "summary": 3-line summary in KOREAN. Each line max 120 chars. Lines separated by "\\n".
- "tags": array of 0 to 3 tags in lowercase kebab-case (English letters and digits only, e.g., "api-timeout", "weekly-retro"). Empty array if no clear tags.
- "due_date": ISO YYYY-MM-DD if you are CONFIDENT about a deadline, else null. Consider rule candidates above as hints but use your own judgment — if multiple ambiguous candidates ("내일 모레", "이번 주 다음 주"), prefer null. If the user wrote "오늘 PR 리뷰" with no deadline implication, return null.

Rules:
- title and summary MUST be written in Korean regardless of input language.
- tags MUST be English kebab-case (for consistency across notes; easier to search/group).
- due_date MUST be ISO YYYY-MM-DD format or null. Never include time-of-day.
- Do NOT invent facts not present in the input.
- Do NOT include markdown code fences or preamble.
- Return ONLY the JSON object.`;
}
```

- [ ] **Step 4: Run prompt tests, verify pass**

Run: `npm test -- prompt`
Expected: PASS — 4 cases.

- [ ] **Step 5: Run full test suite to catch regressions**

Run: `npm test`
Expected: PASS or only LocalOllamaProvider tests fail (string assertions on prompt content). Note any failures for Task 3.

- [ ] **Step 6: Commit**

```bash
git add src/main/ai/prompt.ts tests/unit/prompt.test.ts
git commit -m "$(cat <<'EOF'
feat(tag-vocab): prompt.ts — PROMPT_VERSION 4 + vocab parameter (#3 v0.2.3)

- PROMPT_VERSION 3 → 4 (marker bump, retry 트리거 X)
- buildPrompt 4번째 param vocab: string[] = []
- vocab.length > 0 시 "Existing vocabulary tags" + "Prefer reusing" 라인 추가
- vocab=[] 시 라인 자체 생략 (Q3=B 결정)
- 단위 +4 cases (신규 prompt.test.ts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: InferenceProvider + LocalOllamaProvider — vocab passthrough

**Files:**
- Modify: `src/main/ai/InferenceProvider.ts` (1 line added)
- Modify: `src/main/ai/LocalOllamaProvider.ts:40` (vocab 전달)
- Test: `tests/unit/LocalOllamaProvider.test.ts` (1 case 추가, 기존 string assertion 영향 가능 시 fix)

- [ ] **Step 1: Write failing test — vocab 전달 검증**

Append to `tests/unit/LocalOllamaProvider.test.ts` inside `describe('LocalOllamaProvider')`:

```typescript
it('generate passes vocab into prompt body', async () => {
  let capturedBody: string = '';
  mock.get('http://localhost:11434').intercept({
    path: '/api/generate', method: 'POST'
  }).reply((opts) => {
    capturedBody = opts.body as string;
    return { statusCode: 200, data: JSON.stringify({
      response: JSON.stringify({ title: 't', summary: 'a\nb\nc', tags: ['design'] })
    }) };
  });
  await new LocalOllamaProvider().generate({
    text: 'x', todayKst: '2026-05-02', dueDateCandidates: [],
    vocab: ['design', 'meeting']
  });
  const parsed = JSON.parse(capturedBody) as { prompt: string };
  expect(parsed.prompt).toContain('design, meeting');
  expect(parsed.prompt).toContain('Prefer reusing');
});
```

- [ ] **Step 2: Run test to verify fail**

Run: `npm test -- LocalOllamaProvider`
Expected: FAIL — `vocab` not in `GenerateInput` type, or prompt body missing vocab line.

- [ ] **Step 3: Modify InferenceProvider.ts**

Replace `GenerateInput` interface in `src/main/ai/InferenceProvider.ts`:

```typescript
import type { AiResponse } from './schema.js';
import type { ParseResult } from '../services/dueDateParser.js';

export interface GenerateInput {
  text: string;
  todayKst: string; // ISO YYYY-MM-DD in KST
  dueDateCandidates: ParseResult[];
  vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
}

export interface HealthResult { ok: boolean; model?: string; reason?: string; }

export interface InferenceProvider {
  readonly name: string;
  generate(input: GenerateInput): Promise<AiResponse>;
  healthCheck(): Promise<HealthResult>;
}
```

- [ ] **Step 4: Modify LocalOllamaProvider.ts:40**

Change line 40 from:
```typescript
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates),
```
to:
```typescript
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
```

- [ ] **Step 5: Run LocalOllamaProvider tests, verify pass**

Run: `npm test -- LocalOllamaProvider`
Expected: PASS — new case + all existing cases (vocab default `[]` → 기존 prompt 동작 변경 없음).

- [ ] **Step 6: Commit**

```bash
git add src/main/ai/InferenceProvider.ts src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts
git commit -m "$(cat <<'EOF'
feat(tag-vocab): InferenceProvider.vocab + LocalOllamaProvider 전달 (#3 v0.2.3)

- GenerateInput.vocab?: string[] (optional, 미전달 시 빈 배열 처리)
- LocalOllamaProvider.generate 가 input.vocab ?? [] 를 buildPrompt 4th 인자로
- 단위 +1 case (vocab → prompt body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: telemetryEvents — TagVocabHit + TagVocabMiss zod schemas

**Files:**
- Modify: `src/main/services/telemetryEvents.ts` (after `AiRetryManualPayload`, expand union 13→15)
- Test: `tests/unit/telemetryEvents.test.ts` (+3 cases)

- [ ] **Step 1: Write failing tests**

Append to `tests/unit/telemetryEvents.test.ts`:

```typescript
describe('validateEvent — tag vocab', () => {
  it('accepts tag_vocab_hit event', () => {
    const e = validateEvent({
      ts: '2026-05-02T00:00:00.000Z',
      kind: 'tag_vocab_hit',
      payload: { tagId: 42, vocabSize: 17 }
    });
    expect(e.kind).toBe('tag_vocab_hit');
  });

  it('accepts tag_vocab_miss event without tagId', () => {
    const e = validateEvent({
      ts: '2026-05-02T00:00:00.000Z',
      kind: 'tag_vocab_miss',
      payload: { vocabSize: 17 }
    });
    expect(e.kind).toBe('tag_vocab_miss');
  });

  it('rejects tag_vocab_hit with extra field (privacy invariant)', () => {
    expect(() => validateEvent({
      ts: '2026-05-02T00:00:00.000Z',
      kind: 'tag_vocab_hit',
      payload: { tagId: 42, vocabSize: 17, tagName: 'leak' }
    })).toThrow();
  });
});
```

- [ ] **Step 2: Run tests, verify fail**

Run: `npm test -- telemetryEvents`
Expected: FAIL — `tag_vocab_hit` not in discriminator union.

- [ ] **Step 3: Modify telemetryEvents.ts**

Append after `AiRetryManualPayload` (line 51):

```typescript
const TagVocabHitPayload = z.object({
  tagId: z.number().int().positive(),
  vocabSize: z.number().int().nonnegative()
}).strict();

const TagVocabMissPayload = z.object({
  vocabSize: z.number().int().nonnegative()
}).strict();
```

Append to `TelemetryEventSchema` union (after `ai_retry_manual` entry, line 66):

```typescript
  z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict()
```

(Add comma after `ai_retry_manual` line.)

- [ ] **Step 4: Run tests, verify pass**

Run: `npm test -- telemetryEvents`
Expected: PASS — 3 new cases.

- [ ] **Step 5: Commit**

```bash
git add src/main/services/telemetryEvents.ts tests/unit/telemetryEvents.test.ts
git commit -m "$(cat <<'EOF'
feat(tag-vocab): telemetryEvents — tag_vocab_hit/miss zod schemas (#3 v0.2.3)

- TagVocabHitPayload { tagId: int>0, vocabSize: int>=0 } .strict()
- TagVocabMissPayload { vocabSize: int>=0 } .strict()
- TelemetryEventSchema union 13 → 15
- 단위 +3 cases (hit accept, miss accept, hit extra field 거부)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: telemetryStats — tag_vocab_hit/miss 누적 + summary

**Files:**
- Modify: `src/main/services/telemetryStats.ts` (DailyRow + accumulators + table + summary)
- Test: `tests/unit/telemetryStats.test.ts` (+1 case)

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

Append to `tests/unit/telemetryStats.test.ts`:

```typescript
it('aggregates tag_vocab hit/miss with success rate', () => {
  const events: TelemetryEvent[] = [
    e('2026-05-02T00:00:00Z', 'tag_vocab_hit', { tagId: 1, vocabSize: 10 }),
    e('2026-05-02T00:00:01Z', 'tag_vocab_hit', { tagId: 2, vocabSize: 10 }),
    e('2026-05-02T00:00:02Z', 'tag_vocab_hit', { tagId: 3, vocabSize: 10 }),
    e('2026-05-02T00:00:03Z', 'tag_vocab_hit', { tagId: 4, vocabSize: 10 }),
    e('2026-05-02T00:00:04Z', 'tag_vocab_hit', { tagId: 5, vocabSize: 10 }),
    e('2026-05-02T00:00:05Z', 'tag_vocab_miss', { vocabSize: 10 }),
    e('2026-05-02T00:00:06Z', 'tag_vocab_miss', { vocabSize: 10 }),
    e('2026-05-02T00:00:07Z', 'tag_vocab_miss', { vocabSize: 10 })
  ];
  const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z'));
  expect(r.md).toContain('태그 vocab: hit/miss = 5/3');
  expect(r.md).toContain('적중률 62.5%');
});

it('태그 vocab summary shows 데이터 없음 when no events', () => {
  const r = aggregateStats([], new Date('2026-05-03T00:00:00Z'));
  expect(r.md).toContain('태그 vocab');
  expect(r.md).toContain('데이터 없음');
});
```

- [ ] **Step 2: Run test, verify fail**

Run: `npm test -- telemetryStats`
Expected: FAIL — summary 라인 없음.

- [ ] **Step 3: Modify telemetryStats.ts**

In `DailyRow` interface (line 12), add 2 fields after `ai_retry_manual`:

```typescript
  ai_retry_manual: number;
  tag_vocab_hit: number;
  tag_vocab_miss: number;
}
```

In `aggregateStats` add 2 accumulators (after `aiRetryManualFailedSum`, around line 49):

```typescript
  let aiRetryManualFailedSum = 0;
  let tagVocabHitCount = 0;
  let tagVocabMissCount = 0;
```

In row init block (around line 54), add 2 fields:

```typescript
      row = {
        date: day,
        capture: 0, ai_succeeded: 0, ai_failed: 0,
        trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0,
        expired_banner_shown: 0, expired_batch_trash: 0,
        ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0,
        ai_retry_manual: 0,
        tag_vocab_hit: 0, tag_vocab_miss: 0
      };
```

In if/else if chain (after `ai_retry_manual` branch, line 102), add 2 branches:

```typescript
    } else if (ev.kind === 'tag_vocab_hit') {
      row.tag_vocab_hit += 1;
      tagVocabHitCount += 1;
    } else if (ev.kind === 'tag_vocab_miss') {
      row.tag_vocab_miss += 1;
      tagVocabMissCount += 1;
    }
```

After `const totalUnreachable = ...` (line 117), add:

```typescript
  const tagVocabTotal = tagVocabHitCount + tagVocabMissCount;
  const tagVocabSummary = tagVocabTotal === 0
    ? '(데이터 없음)'
    : `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`;
```

In table header line (line 126), append columns:

```typescript
  lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss |');
  lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|');
```

In table row (line 129), append:

```typescript
    lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} |`);
```

After "AI 수동 재시도" line (line 141), add:

```typescript
  lines.push(`- 태그 vocab: ${tagVocabSummary}`);
```

- [ ] **Step 4: Run test, verify pass**

Run: `npm test -- telemetryStats`
Expected: PASS — 2 new cases. Pre-existing cases may need table column adjustment (e.g., `'| 2026-05-01 | 2 | 1 | 0 |'` partial-match assertions still work since they use `toContain`).

- [ ] **Step 5: Commit**

```bash
git add src/main/services/telemetryStats.ts tests/unit/telemetryStats.test.ts
git commit -m "$(cat <<'EOF'
feat(tag-vocab): telemetryStats — hit/miss 누적 + summary 적중률 (#3 v0.2.3)

- DailyRow +2 cols (tag_vocab_hit, tag_vocab_miss)
- accumulators + branches
- table 컬럼 +2
- summary "- 태그 vocab: hit/miss = N/M (적중률 X%)" 또는 "(데이터 없음)"
- 단위 +1 case

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: TelemetryService — EmitInput union + narrowing guards

**Files:**
- Modify: `src/main/services/TelemetryService.ts:18-31` (EmitInput union)
- Modify: `tests/unit/TelemetryService.test.ts:151,167` (narrowing guards 확장)

- [ ] **Step 1: Modify EmitInput union**

In `src/main/services/TelemetryService.ts`, replace `EmitInput` type (line 18-31):

```typescript
export type EmitInput =
  | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
  | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
  | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
  | { kind: 'trash'; payload: { noteId: string } }
  | { kind: 'restore'; payload: { noteId: string } }
  | { kind: 'permanent_delete'; payload: { noteId: string } }
  | { kind: 'empty_trash'; payload: { count: number } }
  | { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
  | { kind: 'expired_batch_trash'; payload: { count: number } }
  | { kind: 'ollama_unreachable'; payload: { reason: string } }
  | { kind: 'ollama_recovered'; payload: { downtimeMs: number } }
  | { kind: 'ollama_recheck_manual'; payload: Record<string, never> }
  | { kind: 'ai_retry_manual'; payload: { failedCount: number } }
  | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
  | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } };
```

- [ ] **Step 2: Run typecheck, verify pass**

Run: `npm run typecheck`
Expected: PASS — no errors.

- [ ] **Step 3: Extend narrowing guards in test**

In `tests/unit/TelemetryService.test.ts`, update line 151:

```typescript
    expect(events.map((e) =>
      (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss')
        ? null
        : e.payload.noteId
    )).toEqual(['a', 'b', 'b']);
```

And line 167:

```typescript
    if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.noteId).toBe('a');
```

- [ ] **Step 4: Run TelemetryService tests, verify pass**

Run: `npm test -- TelemetryService`
Expected: PASS — narrowing guards covered both new kinds.

- [ ] **Step 5: Commit**

```bash
git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts
git commit -m "$(cat <<'EOF'
feat(tag-vocab): TelemetryService EmitInput +tag_vocab_hit/miss + 테스트 narrowing 확장 (#3 v0.2.3)

- EmitInput union 13 → 15
- narrowing guards (noteId 없는 kind 분기) 에 tag_vocab_hit/miss 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: AiWorker — vocab fetch + per-tag hit/miss emit

**Files:**
- Modify: `src/main/ai/AiWorker.ts` (AiTelemetryEmitter union 확장 + processJob vocab 흐름)
- Test: `tests/unit/AiWorker.test.ts` (+4 cases)

- [ ] **Step 1: Write failing tests**

Append to `tests/unit/AiWorker.test.ts` inside `describe('AiWorker')`:

```typescript
it('fetches vocab and passes to provider.generate', async () => {
  // Pre-seed 1 note with tag 'design' so vocab non-empty
  const seed = repo.create({ rawText: 'seed' }).id;
  repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });

  const { id } = repo.create({ rawText: 'x' });
  const generateMock = vi.fn(async () => ({
    title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null
  }));
  const w = new AiWorker(repo, makeProvider({ generate: generateMock }), {
    backoffsMs: [0, 0, 0]
  });
  await w.enqueue(id);
  await w.drain();
  expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({
    vocab: expect.arrayContaining(['design'])
  }));
});

it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => {
  // Pre-seed: 'design' in vocab
  const seed = repo.create({ rawText: 'seed' }).id;
  repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });

  const { id } = repo.create({ rawText: 'x' });
  const provider = makeProvider({
    generate: vi.fn(async () => ({
      title: 't', summary: 'a\nb\nc',
      tags: ['design', 'newtag'],  // 1 hit + 1 miss
      dueDate: null
    }))
  });
  const emits: Array<{ kind: string; payload: unknown }> = [];
  const w = new AiWorker(repo, provider, {
    backoffsMs: [0, 0, 0],
    telemetry: {
      emit: vi.fn(async (input) => { emits.push(input); })
    }
  });
  await w.enqueue(id);
  await w.drain();
  const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
  const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
  expect(hit).toHaveLength(1);
  expect(miss).toHaveLength(1);
  expect((hit[0]!.payload as { tagId: number }).tagId).toBeGreaterThan(0);
  expect((hit[0]!.payload as { vocabSize: number }).vocabSize).toBe(1);
  expect((miss[0]!.payload as { vocabSize: number }).vocabSize).toBe(1);
});

it('all tags miss when vocab is empty', async () => {
  // No seed → vocab=[]
  const { id } = repo.create({ rawText: 'x' });
  const provider = makeProvider({
    generate: vi.fn(async () => ({
      title: 't', summary: 'a\nb\nc',
      tags: ['design', 'meeting', 'qa'],
      dueDate: null
    }))
  });
  const emits: Array<{ kind: string; payload: unknown }> = [];
  const w = new AiWorker(repo, provider, {
    backoffsMs: [0, 0, 0],
    telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
  });
  await w.enqueue(id);
  await w.drain();
  const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
  expect(miss).toHaveLength(3);
  expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(0);
});

it('emits one event per tag (3 tags → 3 events)', async () => {
  // Pre-seed: all 3 in vocab
  const seed = repo.create({ rawText: 'seed' }).id;
  repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });

  const { id } = repo.create({ rawText: 'x' });
  const provider = makeProvider({
    generate: vi.fn(async () => ({
      title: 't', summary: 'a\nb\nc',
      tags: ['design', 'meeting', 'qa'],
      dueDate: null
    }))
  });
  const emits: Array<{ kind: string; payload: unknown }> = [];
  const w = new AiWorker(repo, provider, {
    backoffsMs: [0, 0, 0],
    telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
  });
  await w.enqueue(id);
  await w.drain();
  const hits = emits.filter((e) => e.kind === 'tag_vocab_hit');
  expect(hits).toHaveLength(3);
});
```

- [ ] **Step 2: Run tests, verify fail**

Run: `npm test -- AiWorker`
Expected: FAIL — `vocab` not passed to generate, no `tag_vocab_*` emits, AiTelemetryEmitter type rejects new kinds.

- [ ] **Step 3: Extend AiTelemetryEmitter interface**

In `src/main/ai/AiWorker.ts:31-36`, replace:

```typescript
export interface AiTelemetryEmitter {
  emit(input:
    | { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
    | { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
    | { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
    | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
  ): Promise<void>;
}
```

- [ ] **Step 4: Modify processJob — vocab fetch + per-tag emit**

In `src/main/ai/AiWorker.ts:122-167` (processJob method body), make 2 changes:

**4a) Before `provider.generate` call (around line 134), fetch vocab:**

```typescript
        const candidates = parseAllCandidates(note.rawText, todayDate);
        const vocab = this.repo.getTopUsedTags(20);
        const res = await this.provider.generate({
          text: note.rawText,
          todayKst: todayIso,
          dueDateCandidates: candidates,
          vocab
        });
```

**4b) After `this.emit(job.noteId)` (line 165, just before `return`), insert per-tag classification:**

Find this block (around line 155-165):
```typescript
        if (this.telemetry) {
          await this.telemetry.emit({
            kind: 'ai_succeeded',
            payload: {
              noteId: job.noteId,
              durationMs: this.now().getTime() - startMs,
              attempts: attempt + 1
            }
          }).catch(() => {});
        }
        this.emit(job.noteId);
        return;
```

Replace with:
```typescript
        if (this.telemetry) {
          await this.telemetry.emit({
            kind: 'ai_succeeded',
            payload: {
              noteId: job.noteId,
              durationMs: this.now().getTime() - startMs,
              attempts: attempt + 1
            }
          }).catch(() => {});
          // v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장)
          const vocabSet = new Set(vocab);
          for (const tagName of res.tags) {
            if (vocabSet.has(tagName)) {
              const tagId = this.repo.getTagIdByName(tagName);
              if (tagId !== null) {
                await this.telemetry.emit({
                  kind: 'tag_vocab_hit',
                  payload: { tagId, vocabSize: vocab.length }
                }).catch(() => {});
              }
            } else {
              await this.telemetry.emit({
                kind: 'tag_vocab_miss',
                payload: { vocabSize: vocab.length }
              }).catch(() => {});
            }
          }
        }
        this.emit(job.noteId);
        return;
```

- [ ] **Step 5: Run AiWorker tests, verify pass**

Run: `npm test -- AiWorker`
Expected: PASS — 4 new cases + 9 pre-existing cases unchanged.

- [ ] **Step 6: Run full test suite, verify all pass**

Run: `npm test`
Expected: PASS — 363 + 19 = 382 cases.

- [ ] **Step 7: Run typecheck + e2e**

Run: `npm run typecheck && npm run test:e2e`
Expected: typecheck 0 errors, e2e 1/1 pass.

- [ ] **Step 8: Commit**

```bash
git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts
git commit -m "$(cat <<'EOF'
feat(tag-vocab): AiWorker — vocab fetch + per-tag hit/miss emit (#3 v0.2.3)

- processJob 가 generate 직전 repo.getTopUsedTags(20) fetch
- provider.generate 에 vocab 전달 (LocalOllamaProvider 가 prompt 에 주입)
- ai_succeeded emit 후 per-tag 분류 → tag_vocab_hit/miss emit
  - hit: vocabSet.has + getTagIdByName lookup → { tagId, vocabSize }
  - miss: { vocabSize }
- AiTelemetryEmitter union 4종 (ai_succeeded/ai_failed/tag_vocab_hit/tag_vocab_miss)
- 단위 +4 cases (vocab passthrough, hit+miss, vocab=[] all miss, per-tag emit count)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Closure — roadmap mark complete + final gates

**Files:**
- Modify: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (mark #3 complete)

- [ ] **Step 1: Verify final gate matrix**

Run sequentially:
```bash
npm run typecheck
npm test
npm run test:e2e
```

Expected:
- typecheck: 0 errors
- 단위: 363 + 19 = **382/382** (±5 for any LocalOllamaProvider string drift)
- e2e: 1/1

If any failures, fix before proceeding.

- [ ] **Step 2: Mark #3 complete in roadmap**

In `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md`, find the `#3 태그 vocab (6번)` heading or progress table and mark complete (follow same pattern used for #1, #2, #4, #5, #7 — typically a checkbox or status column update).

If roadmap uses progress table style (look at how prior items were marked):
```markdown
| #3 태그 vocab | ✅ 머지 (PR #__) |
```

Adjust to actual roadmap format. Read the file first to confirm pattern.

- [ ] **Step 3: Commit closure**

```bash
git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md
git commit -m "$(cat <<'EOF'
chore(tag-vocab): #3 closure — gates verified + roadmap mark complete

- typecheck 0 / 단위 382 / e2e 1
- v0.2.3 6/7 (#3 태그 vocab 머지)
- 다음: #6 리마인드 spike (마지막 항목)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 4: Push branch**

```bash
git push -u origin feat/v023-tag-vocab
```

- [ ] **Step 5: Create PR via gitea-ops**

Use `gitea-pr` script:
```bash
gitea-pr --title "feat(tag-vocab): #3 태그 vocab — prompt + telemetry (v0.2.3 6/7)" \
  --body "$(cat <<'EOF'
## Summary
v0.2.3 dogfood feedback roadmap §3 #3 cut. AI prompt 에 vocab 주입 + per-tag hit/miss telemetry.

mini-brainstorm 4개 결정:
- Q1=C: vocab pool = AI+user 통합 + kebab-case 필터
- Q2=A: telemetry emit 단위 = 태그별
- Q3=B: prompt 강도 = "Prefer" (우선)
- Q4=A: 기존 노트 재처리 = 자연 진화 (X)

## Changes
- NoteRepository: getTopUsedTags(20) + getTagIdByName(name)
- prompt.ts: PROMPT_VERSION 3 → 4, vocab 4번째 param, vocabBlock
- InferenceProvider/LocalOllamaProvider: vocab passthrough
- AiWorker: vocab fetch + per-tag hit/miss emit
- telemetry: tag_vocab_hit/miss zod schema + stats 누적 + summary
- TelemetryService.EmitInput union 13 → 15

## Test Plan
- [x] typecheck 0
- [x] 단위 363 → 382 (+19)
- [x] e2e 1/1
- [ ] dogfood: vocab 라인이 실제 Ollama 프롬프트에 들어가는지 확인
EOF
)" \
  --head feat/v023-tag-vocab \
  --base main
```

- [ ] **Step 6: Final reviewer dispatch (subagent-driven 모드 시)**

If executing under `superpowers:subagent-driven-development`, dispatch one final code-reviewer over the whole branch range (`main..HEAD`) before opening PR.

---

## Self-Review Checklist (executed inline by plan author)

**1. Spec coverage:**
- ✅ Q1 (vocab pool kebab-case 필터) → Task 1 step 5 case "filters non-kebab-case"
- ✅ Q2 (per-tag emit) → Task 7 case "emits one event per tag (3 tags → 3 events)"
- ✅ Q3 ("Prefer" 강도) → Task 2 case "Prefer reusing"
- ✅ Q4 (자연 진화, retry X) → No retry logic in any task; new notes only path
- ✅ All 7 invariants from spec §3.1 covered (caching X =매 fetch in T7; 빈 vocab → 라인 생략 in T2; tagId via getTagIdByName in T7; PROMPT_VERSION marker bump in T2; vocab snapshot via local var in T7; emit 후 순서 in T7)
- ✅ Privacy invariant: T4 case "rejects extra field" + payload schemas omit tag name

**2. Placeholder scan:**
- No "TBD", "TODO", or "fill in details" anywhere
- Step 2 of Task 8 references "follow pattern used for prior items" — actionable since prior pattern exists in same doc

**3. Type consistency:**
- `getTopUsedTags(limit)` 시그니처 (T1) === T7 호출부 `repo.getTopUsedTags(20)`
- `getTagIdByName(name): number | null` (T1) === T7 호출부 `repo.getTagIdByName(tagName)` + null check
- `GenerateInput.vocab?: string[]` (T3) === T7 `provider.generate({ ..., vocab })` (matches optional)
- `AiTelemetryEmitter` 4-kind union (T7) === `TelemetryService.EmitInput` superset (T6)
- `TagVocabHitPayload.tagId: int positive` (T4) === AiWorker emit `{ tagId: number; vocabSize: number }` (T7) — `getTagIdByName` returns `number | null`, null path skips emit

**Coverage of 19 tests:**
- T1: 7 (NoteRepository)
- T2: 4 (prompt — version, empty omit, Prefer text, vocab block position)
- T3: 1 (LocalOllamaProvider passthrough)
- T4: 3 (telemetryEvents — hit, miss, privacy reject)
- T5: 2 (telemetryStats — hit/miss aggregation, 데이터 없음)
- T6: narrowing guard updates only (no new test, but extends existing)
- T7: 4 (AiWorker — vocab fetch, hit+miss, all miss, 3 events)

**Total: 7 + 4 + 1 + 3 + 2 + 4 = 21 cases.** Spec budgeted 19 — 2 extra (T5 추가 case + T2 vocab block position)는 acceptable surplus.

---

## Roadmap Relation

- v0.2.3 dogfood feedback #3 (6번째 cut)
- 다음 cut: #6 리마인드 spike (7번째, 마지막)
- v0.2.4 backlog 후속: top-N 튜닝, controlled vocabulary, embeddings dedup
