순서: B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2+C3 → C7 → C9 → T13. B3 (autostart) 위험 task 는 cleanup 시작 직전, fail 시 빠른 회피. 각 task 별 file path / 상세 step / commit message 포함. 신규 단위 추정 +14 (413 → ~427). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
36 KiB
v0.2.6 Bugs + Cleanup Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute task-by-task. Steps use checkbox (
- [ ]).
Goal: Backlog 16건 (4 bug + 12 cleanup → 13 task) 통합 처리. dogfood UX 마찰 해소 + 코드베이스 cleanup.
Architecture: Bug fix 4건은 isolated. Cleanup 은 cluster (KST 통합 / TrayCallbacks 객체 / Banner shared) + microfix 묶음. 신규 architecture 변경 거의 없음 — 기존 구조 정리.
Tech Stack: TypeScript strict, vitest 4, better-sqlite3 12.9, React 19, Electron 41.
Spec: docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md
File Structure
| File | Action |
|---|---|
src/main/repository/NoteRepository.ts |
B1 restoreNote + pending_jobs / B2 countTrashed / C6 hydrate cleanup |
src/main/ipc/inboxApi.ts |
B2 IPC handler 갱신 / C9 inbox:trash rename |
src/main/index.ts |
B3 autostart fix / B4 additionalData / C2 createTray 호출 |
src/main/tray.ts |
B3 autostart args / C2 TrayCallbacks 객체화 / C3 singleton 제거 |
src/shared/util/kstDate.ts |
C1 신규 — KST helper 통합 |
src/main/services/TelemetryService.ts |
C1 callsite migrate |
src/main/services/telemetryStats.ts |
C1 callsite migrate / C8 exhaustiveness |
src/main/ai/AiWorker.ts |
C1 callsite migrate / C4 AiFailedReason import |
src/renderer/inbox/store.ts |
C1 snoozeExpired / snoozeRecall migrate |
src/main/services/telemetryEvents.ts |
C4 AiFailedReason 통합 |
src/main/services/TelemetryService.ts |
C4 EmitInput 의 reason type / C5 hasNoteId |
src/renderer/inbox/components/Banner.tsx |
C7 신규 shared component |
src/renderer/inbox/components/{Expiry,Ollama,Failed,Recall}Banner.tsx + OllamaSettingsModal.tsx |
C7 migrate |
src/main/ai/AiWorker.ts |
C9 VOCAB_TOP_N const |
src/renderer/inbox/components/OllamaSettingsModal.tsx |
C9 URL pre-check |
src/main/services/telemetryStats.ts (또는 callsite) |
C9 휴지통 회수율 코멘트 |
package.json + package-lock.json |
T13 0.2.5 → 0.2.6 |
총 신규 단위 +14 (추정): B1 (3), B2 (2), B3 (1), B4 (1), C1 (2), C5 (2), C8 (1), C9 (1), 나머지 refactor 0.
작업 순서 (subagent dispatch 순서)
순서: B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2+C3 → C7 → C9 → closure
이유 (spec §9): B3 (위험) cleanup 시작 직전. C2+C3 묶음. C7 (UI cleanup) 마지막 그룹.
Task B1: #10 restoreNote + pending_jobs 재생성
Files:
- Modify:
src/main/repository/NoteRepository.ts - Test:
tests/unit/NoteRepository.test.ts
Bug 설명: 사용자가 노트 trash → AI fail 발생 → restore 시 ai_status='failed' 그대로, pending_jobs 미재생성. AI 재처리 path 없어 영구 fail.
- Step 1: Write failing test
Append to tests/unit/NoteRepository.test.ts:
it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.markAiFailed(id, 'unreachable');
repo.trash(id, new Date().toISOString());
expect(repo.findById(id)!.aiStatus).toBe('failed');
repo.restoreNote(id);
const after = repo.findById(id)!;
expect(after.deletedAt).toBeNull();
expect(after.aiStatus).toBe('pending');
expect(after.aiError).toBeNull();
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
expect(job).toBeDefined();
});
it('restoreNote does not re-enqueue done note', () => {
const id = repo.create({ rawText: 'x' }).id;
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
repo.trash(id, new Date().toISOString());
expect(repo.findById(id)!.aiStatus).toBe('done');
repo.restoreNote(id);
expect(repo.findById(id)!.aiStatus).toBe('done');
const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
expect(job).toBeUndefined();
});
it('restoreNote does not re-enqueue pending note (already enqueued)', () => {
const id = repo.create({ rawText: 'x' }).id;
// pending 상태로 trash
db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id); // 인공적으로 cleanup
repo.trash(id, new Date().toISOString());
expect(repo.findById(id)!.aiStatus).toBe('pending');
repo.restoreNote(id);
// pending 그대로 + pending_jobs 재생성 (없으면)
expect(repo.findById(id)!.aiStatus).toBe('pending');
});
- Step 2: Run, verify fail
Run: npm test -- NoteRepository
Expected: 1+ FAIL — restoreNote 가 ai_status reset 안 함.
- Step 3: Implement
src/main/repository/NoteRepository.ts — restoreNote 메서드 찾아 변경:
restoreNote(id: string): void {
const tx = this.db.transaction(() => {
const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined;
const now = new Date().toISOString();
this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id);
// v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성
if (before?.ai_status === 'failed') {
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') {
// pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성
this.db.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
).run(id, now);
}
// done 노트는 재처리 안 함 (이미 결과 있음)
});
tx();
}
- Step 4: Run, verify pass
Run: npm test -- NoteRepository
Expected: PASS — 3 new cases.
- Step 5: Commit
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "$(cat <<'EOF'
fix(v026): #10 restoreNote 가 failed 노트 시 pending_jobs 재생성
restore 가 deleted_at = NULL 만 했음 → ai_status='failed' 인 노트 는
영구 fail 상태로 복구. atomic transaction 안에서 ai_status='pending'
+ pending_jobs INSERT OR IGNORE.
- failed → pending 재처리 path 복구
- done 은 영향 X (이미 결과 있음)
- pending 은 pending_jobs 재생성 (정상화)
- 단위 +3 cases (failed/done/pending 각 케이스)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task B2: #12 trashCount cap dialog 정확도
Files:
- Modify:
src/main/repository/NoteRepository.ts(countTrashed) - Modify:
src/main/ipc/inboxApi.ts(handler 갱신) - Test:
tests/unit/NoteRepository.test.ts
Bug 설명: emptyTrash dialog 가 "200개 영구 삭제" 표시하지만 실제 350 trash 시 350 모두 삭제 → silent undercount.
- Step 1: Read current
inbox:trashCounthandler
In src/main/ipc/inboxApi.ts, find current inbox:trashCount 또는 비슷한 IPC. 현재 limit 200 으로 capped 되어 있음 확인.
- Step 2: Write failing test
Append to tests/unit/NoteRepository.test.ts:
it('countTrashed returns accurate count (>200 not capped)', () => {
const now = new Date().toISOString();
for (let i = 0; i < 250; i++) {
const id = repo.create({ rawText: `n${i}` }).id;
repo.trash(id, now);
}
expect(repo.countTrashed()).toBe(250);
});
it('countTrashed returns 0 for empty trash', () => {
expect(repo.countTrashed()).toBe(0);
});
- Step 3: Run, verify fail
Run: npm test -- NoteRepository
Expected: FAIL — countTrashed 메서드 없음.
- Step 4: Implement countTrashed
In NoteRepository.ts add (near countToday / countFailed):
countTrashed(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`)
.get() as { c: number };
return row.c;
}
- Step 5: Update IPC handler
Find existing IPC that returns trash count (likely inbox:trashCount or similar). Replace cap-200 query with deps.repo.countTrashed() call. UI 의 emptyTrash confirm dialog 가 이 N 사용.
ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed());
(만약 기존 handler 가 list 일부만 가져와 length 반환하면 그 부분 제거 + countTrashed 사용)
- Step 6: Run all tests
Run: npm test
Expected: 413 → 415 pass.
- Step 7: Commit
git add src/main/repository/NoteRepository.ts src/main/ipc/inboxApi.ts tests/unit/NoteRepository.test.ts
git commit -m "$(cat <<'EOF'
fix(v026): #12 trashCount cap → countTrashed() 정확 N (silent undercount 해소)
기존 UI 가 list 200 limit 후 length 사용 → 350개 trash 시 dialog
"200개 영구 삭제" 표시되지만 실제 350 모두 삭제. 사용자 혼동.
- NoteRepository.countTrashed() 신규 — SELECT COUNT(*) WHERE deleted_at IS NOT NULL
- IPC handler inbox:trashCount 가 countTrashed 사용
- emptyTrash confirm dialog 의 N 정확화
- 단위 +2 cases (>200 not capped, empty 0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task B4: #46 hidden-start race (additionalData)
Files:
- Modify:
src/main/index.ts(single-instance lock 부분)
Bug 설명: NSIS installer 가 설치 직후 사용자 클릭 (inkling.exe) + autostart (inkling.exe --hidden) 두 instance 짧은 간격 시 — 첫 lock 보유자에 따라 visible 여부 race.
- Step 1: Read current single-instance lock code
src/main/index.ts line 36-58 영역 (PR #23 으로 추가된 lock + second-instance handler).
- Step 2: Modify lock + handler
Replace existing block:
const HIDDEN_ARG = '--hidden';
const startedHidden = process.argv.includes(HIDDEN_ARG);
// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46).
// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창
// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로.
const additionalData = { hidden: startedHidden };
const gotLock = app.requestSingleInstanceLock(additionalData);
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', (_e, _argv, _cwd, secondData) => {
const data = secondData as { hidden?: boolean } | undefined;
// 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음
if (data?.hidden === true) return;
// 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게
const win = getInboxWindow();
if (win) {
if (win.isMinimized()) win.restore();
if (!win.isVisible()) win.show();
win.focus();
} else {
createInboxWindow();
}
});
}
- Step 3: Run typecheck + tests
Run: npm run typecheck
Expected: 0 errors.
Run: npm test
Expected: 415/415 (no test impact — defensive fix).
- Step 4: Commit
git add src/main/index.ts
git commit -m "$(cat <<'EOF'
fix(v026): #46 hidden-start race — additionalData 로 두 번째 hidden 구분
PR #23 single-instance lock 의 second-instance handler 가 무조건 inbox 창
띄움. NSIS installer 직후 사용자 클릭 + autostart --hidden 동시 시도 시
첫 lock 보유자가 hidden 이면 두 번째 (visible 사용자 클릭) 가 띄움 — OK.
첫 lock 이 visible (사용자) 이면 두 번째 (hidden autostart) 가 또 inbox 띄움
→ 의도 위반. additionalData 로 두 번째의 hidden flag 전달 → hidden 이면 skip.
PR #23 round 1 reviewer Important 처리.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task B3: #45 autostart 풀림 (Windows registry)
Files:
- Modify:
src/main/tray.ts(autostart toggle) - Modify:
src/main/index.ts(firstRun autostart args 정합)
Risky task — Windows registry 디버깅. Fallback: 진단 로그만 추가 + backlog 유지.
- Step 1: Read current autostart code
src/main/tray.ts:47-58 영역. app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] }) + app.getLoginItemSettings() (args 미전달).
- Step 2: Apply 2-part fix
In src/main/tray.ts:
if (app.isPackaged) {
// v0.2.6 #45 — args 명시 전달 → openAtLogin 비교 정확도
const { openAtLogin } = app.getLoginItemSettings({ args: ['--hidden'] });
items.push({
label: '윈도우 시작 시 자동 실행',
type: 'checkbox',
checked: openAtLogin,
click: (item) => {
app.setLoginItemSettings({
openAtLogin: item.checked,
args: ['--hidden']
});
}
});
items.push({ type: 'separator' });
}
In src/main/index.ts (firstRun autostart 영역):
if (app.isPackaged && process.platform === 'win32') {
const initFlag = join(paths.profileDir, '.autostart-init');
if (!existsSync(initFlag)) {
app.setLoginItemSettings({ openAtLogin: true, args: [HIDDEN_ARG] });
writeFileSync(initFlag, new Date().toISOString());
logger.info('autostart.enabled.firstRun');
}
// v0.2.6 #45 — 진단: args 전달 / 미전달 비교 + path 노출
const withArgs = app.getLoginItemSettings({ args: [HIDDEN_ARG] });
const noArgs = app.getLoginItemSettings();
logger.info('autostart.state', {
withArgs: { openAtLogin: withArgs.openAtLogin, executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin },
noArgs: { openAtLogin: noArgs.openAtLogin },
expectedArgs: [HIDDEN_ARG]
});
}
- Step 3: Run typecheck
Run: npm run typecheck
Expected: 0 errors.
Run: npm test
Expected: 415/415.
- Step 4: Commit (fallback acceptable)
If real fix isn't possible (registry 동작 모호), commit 진단 로그만 + backlog #45 유지:
git add src/main/tray.ts src/main/index.ts
git commit -m "$(cat <<'EOF'
fix(v026): #45 autostart 풀림 — args 비교 정확도 + 진단 로그
추정 원인 (a)/(b)/(c): args 비교 mismatch / electron path canonicalization /
registry path vs 현재 process path.
Fix:
- getLoginItemSettings({ args: ['--hidden'] }) — 트레이 메뉴 checked 상태가
실제 args 와 일치하는지 정확 비교
- index.ts firstRun 후 autostart.state 진단 로그 (withArgs vs noArgs 비교)
- dogfood 에서 로그 확인 후 v0.2.7 에서 deeper fix 검토 가능
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task C1: #3+#19+#34 KST helper 통합
Files:
- Create:
src/shared/util/kstDate.ts - Test:
tests/unit/kstDate.test.ts(확장 기존 또는 신규) - Modify:
src/main/services/TelemetryService.ts(todayKstIso callsite) - Modify:
src/main/services/telemetryStats.ts(kstDate callsite) - Modify:
src/main/ai/AiWorker.ts(todayKstAsDate/Iso callsite) - Modify:
src/renderer/inbox/store.ts(snoozeExpired/snoozeRecall callsite)
기존 tests/unit/kstDate.test.ts 가 있다면 확장. 없으면 신규.
- Step 1: Check existing kstDate
ls c:/Users/rlaxo/inkling/src/main/util/kstDate.ts 2>&1
ls c:/Users/rlaxo/inkling/src/shared/util/ 2>&1
기존 @main/util/kstDate.ts 있으면 그 코드 가져와 src/shared/util/kstDate.ts 로 이동 + alias 삭제.
- Step 2: Create/move kstDate.ts to shared
src/shared/util/kstDate.ts:
export const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
export const DAY_MS = 24 * 60 * 60 * 1000;
/** KST 자정 기준 today YYYY-MM-DD. */
export function kstTodayIso(now: Date = new Date()): string {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
.toISOString().slice(0, 10);
}
/** 다음 KST 자정의 ms timestamp (UTC). */
export function nextKstMidnightMs(now: Date = new Date()): number {
const nowMs = now.getTime();
const kstNow = nowMs + KST_OFFSET_MS;
const kstMidnightFloor = Math.floor(kstNow / DAY_MS) * DAY_MS;
const nextKstMidnight = kstMidnightFloor + DAY_MS;
return nextKstMidnight - KST_OFFSET_MS;
}
/** KST today (00:00 KST 의 UTC Date). AiWorker 의 candidate parsing 용. */
export function kstTodayAsDate(now: Date = new Date()): Date {
const k = new Date(now.getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()));
}
- Step 3: Tests
tests/unit/kstDate.test.ts (신규 또는 확장):
import { describe, it, expect } from 'vitest';
import { kstTodayIso, nextKstMidnightMs, kstTodayAsDate, KST_OFFSET_MS, DAY_MS } from '@shared/util/kstDate.js';
describe('kstDate', () => {
it('kstTodayIso returns YYYY-MM-DD format in KST', () => {
const utcDate = new Date('2026-05-04T15:30:00Z'); // KST 5/5 00:30
expect(kstTodayIso(utcDate)).toBe('2026-05-05');
});
it('nextKstMidnightMs returns next KST midnight (UTC ms)', () => {
const utcDate = new Date('2026-05-04T15:30:00Z'); // KST 5/5 00:30
const next = nextKstMidnightMs(utcDate);
// next KST midnight = 5/6 00:00 KST = 5/5 15:00 UTC
expect(new Date(next).toISOString()).toBe('2026-05-05T15:00:00.000Z');
});
});
- Step 4: Migrate callsites
Each callsite: import from @shared/util/kstDate.js 대체.
src/main/services/TelemetryService.ts: 기존todayKstIso(now)→kstTodayIso(now). KST_OFFSET_MS 로컬 const 제거.src/main/services/telemetryStats.ts:kstDate(ts)→kstTodayIso(new Date(ts)). KST_OFFSET_MS 로컬 const 제거.src/main/ai/AiWorker.ts:todayKstAsDate(now)→kstTodayAsDate(now).todayKstAsIso(now)→kstTodayIso(now). KST_OFFSET_MS 로컬 const 제거.src/renderer/inbox/store.ts:snoozeExpired+snoozeRecall의 inline KST 계산 →nextKstMidnightMs()호출.
각 파일 변경 후:
npm run typecheck
npm test
Expected: 415 → 417 (+2 from kstDate.test).
- Step 5: Commit
git add src/shared/util/kstDate.ts tests/unit/kstDate.test.ts \
src/main/services/TelemetryService.ts src/main/services/telemetryStats.ts \
src/main/ai/AiWorker.ts src/renderer/inbox/store.ts
# 기존 src/main/util/kstDate.ts 가 있다면 git rm 추가
git commit -m "$(cat <<'EOF'
refactor(v026): #3+#19+#34 KST helper 통합 → src/shared/util/kstDate.ts
main + renderer 양쪽에서 import 가능한 단일 util.
- kstTodayIso(now): YYYY-MM-DD KST 자정 기준
- nextKstMidnightMs(now): 다음 KST 자정 UTC ms
- kstTodayAsDate(now): KST 자정의 UTC Date
- KST_OFFSET_MS, DAY_MS 상수 노출
Migrate 4 callsite:
- TelemetryService.todayKstIso
- telemetryStats.kstDate
- AiWorker.todayKstAsDate/Iso
- store.snoozeExpired + snoozeRecall (inline KST 계산)
단위 +2 cases (kstTodayIso, nextKstMidnightMs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task C4: #5 AiFailedReason union 통합
Files:
-
Modify:
src/main/services/telemetryEvents.ts(zod enum 단일 export) -
Modify:
src/main/services/TelemetryService.ts(EmitInput reason type) -
Modify:
src/main/ai/AiWorker.ts(classifier + emitter) -
Step 1: Export AiFailedReason from telemetryEvents.ts
In src/main/services/telemetryEvents.ts:
export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']);
export type AiFailedReason = z.infer<typeof AiFailedReasonSchema>;
기존 const AiFailedReason = z.enum(...) 를 위 export 로 교체.
- Step 2: Migrate callsites
src/main/services/TelemetryService.ts EmitInput:
import type { AiFailedReason } from './telemetryEvents.js';
// ...
| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } }
src/main/ai/AiWorker.ts:
classifyReason의 반환 type →AiFailedReasonAiTelemetryEmitterinterface 의reasonfield type →AiFailedReason
기존 inline 'unreachable' | 'schema' | 'timeout' | 'other' literal 모두 AiFailedReason 으로 교체.
- Step 3: Verify
npm run typecheck
npm test
Expected: 417/417 (refactor only).
- Step 4: Commit
git add src/main/services/telemetryEvents.ts src/main/services/TelemetryService.ts src/main/ai/AiWorker.ts
git commit -m "$(cat <<'EOF'
refactor(v026): #5 AiFailedReason union 단일 export 통합
기존 'unreachable' | 'schema' | 'timeout' | 'other' literal 이 3곳 (zod enum,
EmitInput, AiWorker classifier+emitter) 에 분산. zod enum z.infer 통해
type 파생, 단일 export AiFailedReason 으로 통합.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task C5: #21 hasNoteId type predicate
Files:
-
Modify:
src/main/services/telemetryEvents.ts(또는src/main/services/telemetryGuards.ts신규) -
Modify:
tests/unit/TelemetryService.test.ts(narrowing 체인 단축) -
Test:
tests/unit/telemetryEvents.test.ts또는 신규 -
Step 1: Add hasNoteId predicate
In src/main/services/telemetryEvents.ts (또는 신규 telemetryGuards.ts):
import type { TelemetryEvent } from './telemetryEvents.js';
const NO_NOTE_ID_KINDS = new Set([
'empty_trash', 'expired_banner_shown', 'expired_batch_trash',
'ollama_unreachable', 'ollama_recovered', 'ollama_recheck_manual',
'ai_retry_manual', 'tag_vocab_hit', 'tag_vocab_miss'
]);
export function hasNoteId(ev: TelemetryEvent): ev is Extract<TelemetryEvent, { payload: { noteId: string } }> {
return !NO_NOTE_ID_KINDS.has(ev.kind);
}
- Step 2: Migrate
tests/unit/TelemetryService.test.ts
Find 4-line narrowing chain (e.kind !== 'empty_trash' && ...) — replace with hasNoteId(e):
import { hasNoteId } from '@main/services/telemetryEvents.js';
// ...
expect(events.map((e) => hasNoteId(e) ? e.payload.noteId : null)).toEqual(['a', 'b', 'b']);
// ...
if (hasNoteId(ev)) expect(ev.payload.noteId).toBe('a');
- Step 3: Add hasNoteId tests
Append to tests/unit/telemetryEvents.test.ts:
describe('hasNoteId', () => {
it('returns true for noteId-bearing events (capture / ai_succeeded / trash etc)', () => {
const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
expect(hasNoteId(e1)).toBe(true);
});
it('returns false for noteId-less events (empty_trash / tag_vocab_hit etc)', () => {
const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'empty_trash', payload: { count: 5 } });
const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'tag_vocab_hit', payload: { tagId: 1, vocabSize: 10 } });
expect(hasNoteId(e1)).toBe(false);
expect(hasNoteId(e2)).toBe(false);
});
});
- Step 4: Run, verify pass
npm test
Expected: 417 → 419 (+2).
- Step 5: Commit
git add src/main/services/telemetryEvents.ts tests/unit/TelemetryService.test.ts tests/unit/telemetryEvents.test.ts
git commit -m "$(cat <<'EOF'
refactor(v026): #21 hasNoteId type predicate helper
기존 4-line narrowing 체인 (e.kind !== 'empty_trash' && ... && ...)
이 union 확장 시 길어짐 → hasNoteId(ev) type predicate 로 통합.
NO_NOTE_ID_KINDS Set lookup. Migration: TelemetryService.test.ts 의
2 callsite + 신규 단위 +2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task C6: #22 hydrate as any[] cleanup
Files:
- Modify:
src/main/repository/NoteRepository.ts
기존 db.prepare().all() as any[] 또는 as unknown[] 패턴을 as Record<string, unknown>[] 로 통일. hydrate() 메서드 signature 도 Record<string, unknown> 받도록.
- Step 1: Find all
as any[]/as unknown[]in NoteRepository
grep -n "as any\[\]\|as unknown\[\]" c:/Users/rlaxo/inkling/src/main/repository/NoteRepository.ts
- Step 2: Replace with
as Record<string, unknown>[]
Each occurrence — change cast to as Record<string, unknown>[]. hydrate() signature 가 Record<string, unknown> 받도록 통일 (이미 그럴 가능성 높음).
- Step 3: Verify
npm run typecheck
npm test -- NoteRepository
Expected: PASS — refactor only.
- Step 4: Commit
git add src/main/repository/NoteRepository.ts
git commit -m "$(cat <<'EOF'
refactor(v026): #22 NoteRepository hydrate row type 통일
db.prepare().all() 의 row type cast `as any[]` / `as unknown[]` →
`as Record<string, unknown>[]` 일괄 통일. hydrate() signature 도 동일.
- TS strict 환경 친화
- 향후 explicit row interface 로 추가 narrowing 시 base 형 명확
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task C8: #8 stats.md exhaustiveness check
Files:
-
Modify:
src/main/services/telemetryStats.ts -
Step 1: Add exhaustive
else { _: never }
In aggregateStats 의 if/else if 체인 끝에:
} else if (ev.kind === 'recall_snoozed') {
row.recall_snoozed += 1;
recallSnoozedCount += 1;
} else {
// exhaustiveness check — 새 kind 추가 시 컴파일 단계 catch
const _exhaustive: never = ev;
void _exhaustive;
}
- Step 2: Verify
npm run typecheck
npm test
Expected: PASS.
- Step 3: Commit
git add src/main/services/telemetryStats.ts
git commit -m "$(cat <<'EOF'
refactor(v026): #8 telemetryStats.aggregateStats exhaustiveness check
if/else if 체인 끝에 const _exhaustive: never = ev — 새 telemetry kind
추가 시 본 함수 분기 누락을 컴파일 단계에서 catch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task C2+C3: #4+#23+#26+#27 TrayCallbacks 객체화
Files:
-
Modify:
src/main/tray.ts -
Modify:
src/main/index.ts(createTray 호출) -
Step 1: Define TrayCallbacks interface
In src/main/tray.ts:
export interface TrayCallbacks {
showInbox: () => void;
showCapture: () => void;
runBackup: () => void;
runExport: () => void;
runImport: () => void;
runSync: () => void;
runExportTelemetry: () => void;
runOllamaRecheck: () => void;
runRetryAllFailed: () => void;
runOpenOllamaSettings: () => void;
}
export interface TrayState {
ollamaOk: boolean;
todayCount: number;
failedCount: number;
}
- Step 2: Refactor createTray + module state
기존 module-level _showInbox, _failedCount, _ollamaOk 등 → 단일 module-scoped _callbacks: TrayCallbacks | null + _state: TrayState 로 통합.
let _callbacks: TrayCallbacks | null = null;
let _state: TrayState = { ollamaOk: true, todayCount: 0, failedCount: 0 };
export function createTray(callbacks: TrayCallbacks): void {
_callbacks = callbacks;
// ... 기존 tray 생성 + buildMenu 호출 ...
}
export function refreshTray(state: Partial<TrayState>): void {
_state = { ..._state, ...state };
if (tray) tray.setContextMenu(buildMenu());
}
// buildMenu 안에서 _callbacks!.showInbox 등 호출, _state 참조
기존 refreshTrayOllama(ok), refreshTrayFailedCount(n) → 모두 refreshTray({ ollamaOk: ok }), refreshTray({ failedCount: n }) 로 호출자 변경.
- Step 3: Migrate index.ts
In src/main/index.ts, find existing createTray(...10 args...) call — replace with object:
createTray({
showInbox: () => { ... },
showCapture: () => { ... },
runBackup: () => { ... },
// ... 10 callbacks ...
runOpenOllamaSettings: () => {
const win = getInboxWindow();
if (win) win.webContents.send('inbox:openOllamaSettings');
}
});
기존 refreshTrayOllama(status.ok) 호출 → refreshTray({ ollamaOk: status.ok }).
기존 refreshTrayFailedCount(repo.countFailed()) 호출 → refreshTray({ failedCount: repo.countFailed() }).
기존 refreshTray(repo.countToday()) 호출 → refreshTray({ todayCount: repo.countToday() }).
- Step 4: Verify
npm run typecheck
npm test
Expected: PASS — refactor only.
- Step 5: Commit
git add src/main/tray.ts src/main/index.ts
git commit -m "$(cat <<'EOF'
refactor(v026): #4+#23+#26+#27 TrayCallbacks 객체화 + state 통합
createTray(callbacks: TrayCallbacks) 1-arg signature.
TrayState 통합 (ollamaOk, todayCount, failedCount).
기존 refreshTrayOllama / refreshTrayFailedCount → refreshTray({ ... }).
module-scoped 개별 state 변수 (_failedCount 등) 제거.
backlog #4 (positional 폭주) / #23 (8 callbacks) / #26 (9 callbacks) /
#27 (refreshTrayFailedCount singleton) 일괄 처리. 다음 menu item 추가
시 callback 추가만 — readability blocker 해소.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task C7: #24+#41 Banner shared component
Files:
-
Create:
src/renderer/inbox/components/Banner.tsx -
Modify:
ExpiryBanner.tsx,OllamaBanner.tsx,FailedBanner.tsx,RecallBanner.tsx,OllamaSettingsModal.tsx -
Step 1: Create Banner.tsx
import React from 'react';
const THEMES = {
warning: { bg: '#fff7e6', border: '#d99500', text: '#946100' },
error: { bg: '#fce4e4', border: '#a33', text: '#a33' },
info: { bg: '#e8f0fe', border: '#4a7ec0', text: '#234' }
};
interface Props {
severity: 'warning' | 'error' | 'info';
children: React.ReactNode;
}
export function Banner({ severity, children }: Props): React.ReactElement {
const t = THEMES[severity];
return (
<div style={{
background: t.bg, border: `1px solid ${t.border}`, color: t.text,
borderRadius: 6, padding: '8px 12px', margin: '8px 0', fontSize: 13
}}>
{children}
</div>
);
}
- Step 2: Migrate 4 banners
각 *Banner.tsx 의 outer <div style={{ background: '...', border: '...', ... }}> → <Banner severity="warning|error|info"> 로 교체. 내부 children 유지.
매핑:
-
ExpiryBanner: warning (황색)
-
OllamaBanner: warning (황색)
-
FailedBanner: error (적색)
-
RecallBanner: info (청색)
-
Step 3: Verify
npm run typecheck
npm test
npm run test:e2e
Expected: PASS — UI styling 만 변경, behavior 0.
- Step 4: Commit
git add src/renderer/inbox/components/Banner.tsx \
src/renderer/inbox/components/ExpiryBanner.tsx \
src/renderer/inbox/components/OllamaBanner.tsx \
src/renderer/inbox/components/FailedBanner.tsx \
src/renderer/inbox/components/RecallBanner.tsx
git commit -m "$(cat <<'EOF'
refactor(v026): #24+#41 Banner shared component (severity prop)
4 banner inline style 중복 (warning 황색 / error 적색 / info 청색)
→ <Banner severity="warning|error|info"> wrapper. THEMES map 단일 source.
- ExpiryBanner: warning
- OllamaBanner: warning
- FailedBanner: error
- RecallBanner: info
OllamaSettingsModal 은 modal 형식이라 별개 — banner 와 분리.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task C9: microfixes (#15 #29 #42 #9)
Files:
-
Modify:
src/main/ipc/inboxApi.ts,src/preload/index.ts,src/shared/types.ts(inbox:delete→inbox:trash) -
Modify:
src/main/ai/AiWorker.ts(VOCAB_TOP_Nconst) -
Modify:
src/renderer/inbox/components/OllamaSettingsModal.tsx(URL pre-check) -
Modify:
src/main/services/telemetryStats.ts(휴지통 회수율 ratio 코멘트) -
Step 1: #15 channel rename
inbox:delete→inbox:trash
Search + replace 'inbox:delete' → 'inbox:trash' in: src/main/ipc/inboxApi.ts, src/preload/index.ts, src/shared/types.ts. Renderer 호출자도 메서드명 변경 (또는 InboxApi 의 method name 확인).
만약 channel + method name 이 의미상 일치하면 method name 도 deleteNote → trashNote 변경 검토. 단 backward compat 측면 (renderer 호출자 영향) — minimal scope 면 channel만.
- Step 2: #29 VOCAB_TOP_N const
In src/main/ai/AiWorker.ts:
// 모듈 상단
const VOCAB_TOP_N = 20;
// processJob 내
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
- Step 3: #42 Modal URL pre-check
In src/renderer/inbox/components/OllamaSettingsModal.tsx:
// imports
import { z } from 'zod';
const EndpointSchema = z.string().url();
// handleSave 안, save IPC 직전:
async function handleSave() {
if (saving) return;
setSaving(true);
setError(null);
try {
// v0.2.6 #42 — client-side URL validation
const parseResult = EndpointSchema.safeParse(endpoint);
if (!parseResult.success) {
setError('유효한 URL 형식이 아닙니다 (예: http://localhost:11434)');
return;
}
if (model.trim().length === 0) {
setError('모델명을 입력하세요');
return;
}
// 기존 inboxApi.saveOllamaSettings 호출
const r = await inboxApi.saveOllamaSettings({ endpoint, model });
// ...
- Step 4: #9 휴지통 회수율 ratio 코멘트
In src/main/services/telemetryStats.ts, find trashRecoveryRate 계산 영역 — 1줄 코멘트 추가:
// 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시 100% 가능,
// unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분).
const trashRecoveryRate = trashCount === 0 ? 'N/A' : ...;
- Step 5: Verify
npm run typecheck
npm test
Expected: PASS — 4 microfix 모두 minor.
- Step 6: Commit
git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts \
src/main/ai/AiWorker.ts src/renderer/inbox/components/OllamaSettingsModal.tsx \
src/main/services/telemetryStats.ts
git commit -m "$(cat <<'EOF'
refactor(v026): C9 microfixes — #15 #29 #42 #9
- #15: IPC channel inbox:delete → inbox:trash (semantic = soft delete)
- #29: getTopUsedTags(20) → VOCAB_TOP_N const (튜닝 자체는 telemetry 후)
- #42: OllamaSettingsModal client-side URL validation (zod safeParse pre-check)
- #9: 휴지통 회수율 ratio 의미 1줄 코멘트 (event-level, unique-note 아님)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task T13 (Closure): Version bump + final gates
Files:
-
Modify:
package.json,package-lock.json(0.2.5 → 0.2.6) -
Step 1: Final gates
npm run typecheck
npm test
npm run test:e2e
Expected: typecheck 0 / 단위 ~427 / e2e 1.
- Step 2: Version bump
"version": "0.2.6",
In both package.json + package-lock.json (top + nested "" entry).
- Step 3: Commit closure
git add package.json package-lock.json
git commit -m "$(cat <<'EOF'
chore(release): v0.2.6 — bugs + cleanup (16 backlog 항목 처리)
bugs (4): #10 restore + pending_jobs / #12 trashCount cap / #45 autostart 풀림 /
#46 hidden-start race
cleanup (12 → 9 cluster): KST helper 통합 / TrayCallbacks 객체화 + singleton 제거 /
AiFailedReason union / hasNoteId predicate / hydrate as any[] /
Banner shared component / exhaustiveness check / microfixes (channel rename +
VOCAB_TOP_N + Modal URL pre-check + ratio 코멘트)
게이트: typecheck 0 / 단위 ~427 / e2e 1
잔여 backlog: 14건 (telemetry data-dependent, v0.2.7 brainstorm)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Self-Review
1. Spec coverage:
- ✅ Bug 4건 all (B1~B4)
- ✅ Cleanup 12건 → 9 cluster all
- ✅ Telemetry data-dependent + 별도 brainstorm 영역 명시 out of scope
2. Placeholder scan:
- T13 step 2 의 lock file 위치 — 기존 패턴대로 (top + nested ""). 명시적.
- B3 fallback 처리 — 진단 로그 추가 시점 명시.
3. Type consistency:
kstTodayIso(now: Date = new Date()): string— C1 정의, callsite migration 모두 사용AiFailedReason = 'unreachable' | ...— C4 정의, EmitInput + AiWorker 일관hasNoteId(ev): ev is ...— C5 정의, TelemetryService.test.ts callsite 일관TrayCallbacksinterface — C2 정의, index.ts 호출자 일관
Roadmap relation
- v0.2.6 정식 cut (이전 v0.2.4/v0.2.5 patch/hotfix 와 별개)
- 머지 후 binary v0.2.6 빌드 (Windows + Mac) + Gitea release
- v0.2.7 brainstorm 트리거: dogfood ≥1주 soak + telemetry export 모인 후 잔여 backlog 14건 (data-dependent) + 신규 피드백 일괄 triage
- backlog file 본 cut 후 prune (16 건 처리 완료) + rename 검토