T5 reviewer identified 2 reads outside NoteRepository that were missing the
'WHERE deleted_at IS NULL' filter, breaking the silent invariant beyond the
3 originally-listed methods.
- ContinuityService.get() now excludes trashed notes from streak / weekCount
/ lastNoteAt / recovery-toast math. A trashed note no longer counts toward
weekly streak (regression: streak felt fake after trash).
- NoteRepository.getPendingCount() now excludes trashed-but-still-pending
notes. trash() removes the pending_jobs row but leaves notes.ai_status='pending';
the count would have drifted upward as users trashed pending notes.
- MediaGc.run() gets an inline comment documenting why it intentionally does
NOT filter — trashed notes still own their media until permanentDelete /
emptyTrash. Removing here would defeat restore.
Also: migrations.due_date.test.ts had 2 brittle assertions
(latestVersion()===2, user_version===2) that broke with v3. Migration-system
version assertions belong in migrations.test.ts (already covered there);
m002-specific test keeps the due_date column assertion which is version-stable.
Tests: 245 → 265 (+20). typecheck 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #13 회차 1 리뷰의 actionable 1건 + suggestion 3건 반영.
- `AiWorker` 의 `attempts` 필드가 success/failure 경로에서 비대칭 의미 (0-index vs count) 였던 문제. 둘 다 `attempt + 1` (실제 시도 횟수, 1-based) 로 통일. stats markdown 의 평균/분포 해석이 일관됨.
- `Date.now()` 직접 호출이 `opts.now` DI 를 우회하던 두 곳을 `this.now().getTime()` 으로 교체. 추후 durationMs 분포 테스트 작성 가능.
- `TelemetryService.emit` 의 `this.now()` 두 번 호출을 한 번 캐시로 통합. KST 자정 경계에서 ts 와 파일명 일자 불일치 가능성 제거.
- `readAllRecent` 의 `n.slice(7, 17)` 매직 슬라이스를 정규식 capture 그룹으로 교체. prefix 변경 시 한 곳만 수정.
테스트: AiWorker 성공 케이스의 `attempts: 0` → `attempts: 1` 갱신.
게이트: typecheck 0 errors, 245/245 unit tests pass.
Deferred (v0.2.4 backlog): 'aborted' user-cancel false-positive, tray menu submenu 분리.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Original 'counts events per KST day' test used UTC times that bucket
identically under both KST and naive UTC slice — would not catch a regression
where kstDate was replaced with ev.ts.slice(0,10). Add an explicit
near-midnight case (2026-05-01T15:30Z = 2026-05-02 00:30 KST) that fails
under naive UTC and passes under correct KST conversion.
6 tests pass (was 5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier test used '/proc/0/...' as the unwritable dir. On Windows this
resolved to 'C:\proc\0\...' and mkdir({recursive: true}) silently created
it — the silent code path was never exercised, plus filesystem side-effect
leaked outside the test tmpdir.
Replace with a path that points to an existing file (mkdir on a file path
fails on every platform). Also add a companion test that confirms silent
is opt-in: without {silent: true}, the same fs failure DOES throw.
7 tests pass (was 6).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flow 반전 (F7-D 채택):
- 기존: rule.iso ?? ai.dueDate (rule 우선)
- 신규: ai.dueDate ?? null (AI 우선)
규칙은 parseAllCandidates 로 모든 매치를 추출 → prompt 에 후보
힌트로 주입. AI 가 종합 판단. AI 실패 시 due_date null (별 fallback 없음).
해결되는 케이스: '내일 모레' → AI 가 ambiguous 인지 → null.
PROMPT_VERSION → 3. GenerateInput.dueDateCandidates 신규.
buildPrompt(rawText, todayKst, candidates) — 빈 배열일 때 hint 섹션 생략.
Tests:
- AiWorker.test.ts — 'rule priority' 테스트 → 'AI dueDate wins' flip
- AiWorker.test.ts — passes todayKst 테스트 확장 (dueDateCandidates 도 검증)
- AiWorker.test.ts — 신규 'passes parseAllCandidates result as dueDateCandidates'
- LocalOllamaProvider.test.ts / ollama-golden.test.ts — generate 호출에 dueDateCandidates: [] 추가
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존 parseDueDate (first-match-wins) 는 backward compat 로 보존.
parseAllCandidates 가 모든 high/medium 매치를 text 순서로 반환 — F7
AI-primary flow 의 prompt 후보 주입 입력으로 사용.
Overlapping-span suppression: 다음 주 월요일 같은 케이스에서 rule 10
(전체) 이 rule 13 (다음 주 alone) 을 포함하면 후자 매치 제거.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For the F4-C·F cue strengthening surfaces (tray tooltip + Inbox identity
counter), main + renderer need a single source of truth for "오늘 N번
잡아뒀다". Implements `NoteRepository.countToday(now?)` that computes the
half-open UTC interval covering the KST calendar date of `now` and counts
rows whose `created_at` falls inside.
`now` is injectable for deterministic tests across the KST/UTC boundary
(02:00 KST and 23:00 KST land on different UTC dates yet the same / a
different KST day). Four new cases cover empty DB, KST-day filtering,
KST-midnight crossover, and the default-arg branch.
F6-L2 MVP 의 오케스트레이터.
- isConfigured(): syncDir 가 git repo + origin remote 있을 때만 true
- sync():
1) ExportService.export(<syncDir>, includeMedia: true) — F5 트리 그대로 덮어쓰기
2) git add -A
3) git commit -m "chore(notes): sync <ts>"
4) "nothing to commit" 이면 changed=false 로 정상 반환
5) git push (upstream 미설정이면 -u origin <branch> 자동)
- GitClient.push() 에 hasUpstream() + 자동 -u 추가 (첫 push 케이스)
- 5 vitest cases — bare local remote 로 push 검증, 두 번째 sync 는 변경 없음 확인
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
얇은 git CLI 래퍼. F6-L2 sync MVP 의 빌딩 블록.
- run/isRepo/hasRemote/addAll/commit/push/currentBranch
- commit() 은 "nothing to commit" 을 changed=false 로 구분 (정상 path)
- 그 외 실패는 throw, exitCode + stderr 보존
- 8 vitest cases — empty file 로 GIT_CONFIG_GLOBAL/SYSTEM 격리
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `tagFilter: string | null` and `setTagFilter` to the inbox store, plus
an extracted pure `selectFilteredNotes` selector so unit tests can import it
under vitest's `node` environment without dragging `api.ts` (which touches
`window.inkling` at module load).
Tests cover four cases: null filter passes through, single-tag match,
no-match empty result, and any-tag-matches semantics.
F2 dogfood feedback step 1/3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GenerateInput gains todayKst field. AiWorker computes KST-aligned
date once per job, runs parseDueDate on rawText, calls provider.generate
with todayKst, then merges: rule.iso wins if matched (deterministic),
else AI's due_date, else null. Logs dueDateSource (rule|ai|none) for
debugging. now() injection for testability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AiResponse extends with dueDate: string|null. zod regex
^\d{4}-\d{2}-\d{2}$, follow-up roundtrip check coerces invalid
dates (2026-13-99 etc.) to null. PROMPT_VERSION → 2: prompt now
takes todayKst arg, asks model to extract due_date as ISO or null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regex + KST math, returns ISO YYYY-MM-DD or null. 14 high-confidence
rules (literal date, N월 N일, MM/DD, N일/주/개월 뒤, 모레/내일/글피/오늘,
다음/이번 주 X요일, 다음 달). Ambiguous tokens (월말, 주말, 퇴근 전,
시각) return iso=null + confidence='medium' so caller (AiWorker)
can defer to AI. 26+ golden fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three-state outcome per note: inserted (new id), skipped (id+rawText
match), forked (id match but rawText differs → new uuidv7 to preserve
raw_text invariant from slice §1.1). Media files copied into
MediaStore convention <profileDir>/media/{noteId}/{n}.{ext} with
new media DB rows.
NoteRepository.importNote handles full provenance: ai_status='done',
ai_provider, ai_generated_at, edited flags, intent fields, tags
with source preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExportService composes pure exportFormat layer + reads from
NoteRepository.listAll (new, asc-ordered) + MediaStore.absolutePath
(new helper). Writes notes/{date-id8-slug.md}, media/{id8__n.ext},
index.jsonl, manifest.json, README.md to user-picked dir.
6 unit tests against tmp dirs + :memory: DB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skips when marker matches today's KST date. Marker written after
successful snapshot, before rotation. lastSnapshotAt() exposed for UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review I1: wrap snapshot's backup+rename in try/catch, unlink
orphan tmp file on failure so the next run does not encounter a
confusing 'existing file is not a database' error from sqlite.
Code review I2: JSDoc note that snapshot() is not safe for concurrent
calls — callers should serialize via runDaily()'s marker.
New unit test injects a fake db whose backup() rejects after a partial
write, asserts no .tmp / .sqlite remains.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code reviewer minor nitpicks:
- Add test for inkling-2026-02-30.sqlite (locks roundtrip-validation contract)
- Add test for weekly window inclusive at oldest boundary
- Precompute today=startOfDayUtc(now) once outside the loop, pass to helpers
No behavior change; tests added cover existing semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 20 of the slice plan. Thin wrapper over the repo's intent
methods that adds two preconditions: setIntent rejects empty
trimmed text, both methods throw "note not found" when the
note id doesn't exist. Repo-level COALESCE on
intent_prompted_at preserves the first-prompt invariant
(spec §3.3); IntentService's job is just input guarding so
the IPC handler stays a one-liner.
Verification: `npx vitest run tests/unit/IntentService.test.ts`
4 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 15 of the slice plan. Strategy §4.1 immediate-reward
toast. celebrate(noteId) deterministically picks one of the
4 reward copies via SHA-256(noteId)[0] % 4, then forwards to
the injected send() callback (which Task 30 wires to a real
electron Notification). Skips silently when isSupported() is
false (denied OS permission), and swallows send() errors so
that capture path never fails because of a notification quirk.
Verification: `npx vitest run tests/unit/NotificationService.test.ts`
3 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 14 of the slice plan. Orchestrates a Quick Capture submit:
trims/validates input, creates the note row + pending_jobs row
in one repo.create transaction, persists each pasted PNG via
MediaStore (relPath stored in media table), then awaits the
injected enqueue() (AiWorker.enqueue at runtime) and fires
celebrate() (NotificationService.celebrate). deleteNote drops
the db row (cascading note_tags / media / pending_jobs) and
removes the on-disk media directory afterwards.
Verification: `npx vitest run tests/unit/CaptureService.test.ts`
4 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 13 of the slice plan. Drives the pending → done/failed
transitions:
- enqueue() pushes a Job and kicks the loop; loadFromDb()
rehydrates pending_jobs at startup so app restart resumes
in-flight work.
- drain() exposes a Promise for tests + graceful shutdown.
- Concurrency 1: a single async loop awaits each provider call
before the next, matching spec §2.2.
- 3-attempt backoff (default [0, 30s, 120s]; tests inject [0,0,0]).
Each failure logs ai.retry, increments pending_jobs.attempts,
and on the final attempt calls markAiFailed and emits onUpdate.
- emit() pushes the freshly-hydrated note to onUpdate (used by
Task 30 to fan out IPC note:updated events).
Verification: `npx vitest run tests/unit/AiWorker.test.ts`
4 passed. Suite total 41 / 41.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 12 of the slice plan. Implements the slice's only provider:
- generate(): POST {endpoint}/api/generate with Korean-first
prompt; AbortController-driven 120s timeout; parses
body.response as JSON and runs it through parseAiResponse.
- healthCheck(): GET /api/tags; returns ok when configured model
is in the listing, otherwise reports the missing-model reason.
- Constructor takes opts.endpoint / opts.model so Task 30 main
entry can inject INKLING_OLLAMA_ENDPOINT for LAN dogfood;
defaults are http://localhost:11434 and gemma4:e4b.
Tests: 6 unit cases via undici MockAgent (parse, non-JSON,
timeout abort, healthCheck ok / missing / connection error).
Integration test gated by INKLING_INTEGRATION=1 hits real
Ollama for Korean / English-stack / mixed input cases with a
180s per-test budget — also reads INKLING_OLLAMA_ENDPOINT so
LAN dogfood can be exercised end-to-end.
Plan deviation: undici@8 types treat MockAgent's `.reply()`
callback as sync-return-only, so the 500ms-delayed reply used
in the timeout test is cast `as never` to bypass the overload
mismatch. Behavior is correct at runtime; the cast is local to
the test.
Verification: `npx vitest run tests/unit/LocalOllamaProvider.test.ts`
6 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 10 of the slice plan. parseAiResponse runs the model JSON
through a zod RawResponseSchema, then enforces slice rules:
- title MUST contain Korean characters; throw otherwise.
- summary normalized to exactly 3 lines (pad with empty when too
few; collapse trailing lines into line 3 when too many).
- tags filtered to /^[a-z0-9]+(-[a-z0-9]+)*$/ kebab-case and
capped at 3.
buildPrompt assembles the user-facing prompt with explicit
Korean-output / kebab-tag / no-fence rules (PROMPT_VERSION=1).
Verification: `npx vitest run tests/unit/ai-schema.test.ts`
7 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 9 of the slice plan. Computes the data behind the Inbox
ContinuityBadge and recovery toast (Strategy §5):
- KST Mon-Sun grouping via toKstDateKey/kstMondayOf helpers
(UTC + 9h shift, then bucket to local Monday).
- weekCount + weekTarget=7 + consecutiveCompleteWeeks (walk
backward from current week, or previous if current incomplete,
counting only weeks with >=7 notes).
- showRecoveryToast=true when the latest note lands on today
(KST) and the prior note is at least 7 days earlier — this is
the "흐름을 다시 이어갑니다" trigger.
- now() injected for deterministic tests.
Verification: `npx vitest run tests/unit/ContinuityService.test.ts`
6 passed. All 24 unit tests across migrations / NoteRepository /
MediaStore / ContinuityService green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>