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>
Extracted to own spec with mini-brainstorm decisions captured.
F5 in dogfood-feedback.md marked 🚀 promoted with link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tray now has 4th callback that opens directory chooser, exports all
notes via ExportService with includeMedia=true default. Dialog
message warns about raw_text plain-text + recommends private location.
Native toast on success/failure with note + media counts.
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>
final reviewer 가 식별한 forward-looking polish 4건을 후속 리스트에
명시. 4번째는 F1 (Due Date 마이그레이션 v2) PR 시 즉시 반영 권장:
openDb() 가 마이그레이션을 BackupService 인스턴스화 전 호출하므로
마이그레이션 결함 시 첫 실행 직전 상태 회수 불가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracted to its own spec, dogfood-feedback.md F6 header reflects
L1 promoted status while L2/L3 remain raw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Instantiate BackupService at app.whenReady, run daily snapshot then
again before quit (synchronous-blocking via preventDefault). Tray menu
gets '지금 백업' entry that triggers manual runDaily with native
toast feedback.
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>
Spec reviewer flagged the weekly window keeps 5 Mondays (anchor + 4
prior), not 4 as the plan prose said. Code preserved (tests pass);
constant renamed and comment made honest about the actual semantic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 1: 순수 GFS retention 함수 + 7 단위 테스트
Task 2: BackupService.snapshot() — KST 날짜·tmp+rename 원자성 + 6 단위 테스트
Task 3: runDaily() — .last-snapshot 마커 + lastSnapshotAt + 7 단위 테스트
Task 4: main/index.ts wiring (whenReady + before-quit) + tray '지금 백업'
Task 5: F6-L1 promotion (별 spec 분기 + dogfood-feedback.md 상태 갱신)
backup 위치: <profileDir>/backups/ (mini-brainstorm 결과 A 채택).
스키마 변경 0, 외부 dep 0. better-sqlite3.backup() API 가정.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8개 항목 순차 작업 (F6-L1 → F5 → F6-L3 → F1 → F2 → F3+F4-E →
F6-L2 → F4-C·F) + 데이터 안전 우선 + 머지+테스트 게이트 + 단일
v0.2.1 cut 후 dogfood 재설치 + 1주 soak. F4-A·D 는 측정 후
별 brainstorm 으로 deferred. 항목별 mini-brainstorm 에서
decision-pending 답변하는 라이프사이클.
본 spec 은 순서·범위·게이트만 정의, 항목 내부 설계는 per-item.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
slice + strategy.md §3 가 회의·퇴근 같은 강한 contextual cue 만 다루고
샤워·산책·자기 직전 같은 ambient 떠오름은 사각지대. 6개 심리 메커니즘
(habit stacking, ambient if-then, 환경 앵커, variable interval prompt,
Zeigarnik, 정체성 고리) 후보 + 슬라이스 내/후속 분류 + H1~H5 가설.
권장 순서: E (카피 priming) → 데이터 → C·F → A·D.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dogfood 첫 알림 노출에서 '구출' 표현이 일상 한국어로 부자연스럽다는
피드백. 5개 UI 표면 + e2e 단언 + strategy.md §1·§3·§7 의 어휘 결정에
함께 묶임. drafting 시 결정 대기 #1(strategy 재검토 동반 여부)이
promoted 경로 좌우.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v4/v7 imports stay API-compatible — typecheck and unit suite (52/52)
both pass against the new resolution. The caret range deviates from
the slice's strict-pin invariant (spec §7.1); revisit if reproducible
builds become an issue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reframe the file as a living register for in-flight dogfood feedback
rather than a single-feature stub. Each item carries a status label
(raw / drafting / ready-for-spec / promoted / rejected) and a fixed
six-slot template (관찰 / 제안 방향 / 결정 대기 / 가설·측정 / 범위 /
영향). Items graduate to their own spec file once mature; the entry
here then collapses to a one-line link.
- F1: Due-date 추출 (drafting) — content from the previous stub
normalized to the new template.
- F2: 태그 클릭 = 즉시 삭제 + undo 부재 (raw) — NoteCard.tsx:110
binds chip onClick to removeTag, no confirm or undo, and there is
no "filter by tag" affordance to match the user's mental model.
Direction: split click=filter, ✕-icon=remove with 5s undo toast.
README docs map updated to point at the new path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The due-date extraction work originated as feedback during dogfood, but
the dogfood strategy doc itself should stay feature-agnostic — it's the
generic operating manual for the 2-week dogfood, not a feedback log.
- Remove the "시간 표현 포함 노트 수" row from dogfood-strategy §3.1.
- Rephrase the due-date spec stub so H1 / §7 / §9 reference the spec's
own sample-collection plan instead of relying on the dogfood retro.
- Spec is now framed as "independent of slice exit"; entry timing is a
separate decision once an accumulated sample meets H1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the design skeleton, hypotheses, and open questions for the
hybrid (rule-based first → AI fallback) Korean date-phrase extractor.
Schema impact, prompt change, UX copy, and library tradeoffs are
sketched but deferred — formalization waits for slice v0.4 dogfood
Pass and the H1 (time-phrase frequency ≥ 30%) check in week-1 retro.
Cross-link added to dogfood-strategy daily-metric table so the H1
data is collected during dogfood, and to README docs map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the OllamaBanner appears, it was generic enough that the user
couldn't tell whether the env var was missing, the LAN host was
unreachable, the model was uninstalled, or DNS was failing. Log the
resolved endpoint at startup with a `fromEnv` flag so we can confirm
INKLING_OLLAMA_ENDPOINT was actually read, and render the underlying
health-check reason as a small subtitle under the banner copy.
The user-facing primary message still avoids the forbidden tone words
("실패"/"끊김"/"연속 실패"); the diagnostic line is technical and only
appears when status.reason is set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Vitest needs the node-ABI prebuilt sqlite binary; Electron 41 needs ABI 145.
Add `rebuild:node` / `rebuild:electron` scripts and wire them as
pre-hooks for `test`, `test:e2e`, `start`, `dev`, `test:integration`.
- Smoke test was racing on `firstWindow()`, which non-deterministically
returned the quickcapture window. Strip ELECTRON_RUN_AS_NODE from the
launch env, wait for both windows to register, then pick the inbox by
title and assert the heading via `getByRole`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to the CJS refactor: destructuring electron.BrowserWindow /
electron.Tray pulled the values into scope but lost the type
namespace, breaking sites that used the bare class name as a type
(let win: BrowserWindow | null, return-type annotations,
implicitly-any close handlers). Added 'import type { ... as ...Type }'
for each affected file and replaced the type-position references
with the aliased names. Runtime semantics unchanged; typecheck
exits 0 again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 32 of the slice plan. playwright.config.ts (testDir
tests/e2e, single worker, 60s default timeout, list reporter)
and a smoke spec that launches the built main bundle, waits
for the inbox renderer, and asserts the v0.2 empty-state copy
'첫 기억을 구출해보세요.' is rendered.
Build verified end-to-end (`npm run build` exits 0, produces
out/main/index.js + out/preload/index.mjs + the inbox /
quickcapture renderer chunks). Playwright's chromium bundle
installed.
Known issue (deferred to a follow-up debug session): on this
specific Windows machine, electron@41.3.0's main-process
module hook is not injecting the real `electron` API into the
launched bundle. `require('electron')` from the entry script
returns the on-disk path string (the package's index.js
default), and `process.electronBinding` / `process.type` are
undefined inside the spawned process. This reproduces with a
minimal package.json + main.js outside this repo — i.e. it's
not a slice bug. The smoke test currently fails with
'Process failed to launch!' at the
`app.whenReady().then(...)` line because `app` is undefined.
When the host issue is sorted, no test changes should be
required: build, config, and spec are correct. Likely fixes:
clean-reinstall electron + node-modules, try electron@latest,
or invoke through `npx electron` rather than the binary
directly. Captured in project_inkling_status memory under
'open issues'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related runtime defects surfaced when first attempting
`npm run build` + Electron launch:
1. Renderer html files referenced `/src/.../main.tsx`, which
vite dev resolves but vite production rollup cannot. Changed
both inbox and quickcapture to `./main.tsx` (sibling-relative).
2. Electron 41's main-process module hook only injects the
real `electron` API into `require('electron')` calls inside
CommonJS modules. With package.json `"type": "module"` set,
electron-vite emitted ESM bundles that did
`import { app } from "electron"`; Node's ESM CJS-interop
then loaded the on-disk index.js (which exports the binary
path string) instead, leaving `app` undefined at startup.
Fix: drop `"type": "module"` so electron-vite emits CJS for
main + preload (renderer is unaffected — vite handles its own
ESM transform regardless). Source files keep modern import
syntax via the default-import + destructure pattern
(`import electron from 'electron'; const { app } = electron;`)
which transpiles correctly to `const { app } = require('electron')`
in the CJS output.
Also updated `electron-log/main` -> `electron-log/main.js`
because Node ESM strict resolution requires the `.js` extension
even though the package's CJS entry serves both.
Verification: `npm run build` produces 47-module renderer +
1005KB main bundle without errors. `npm run typecheck` clean.
Unit suite (52 tests) unaffected.
Plan deviation log: Task 4 (logger), Task 30 (main wiring), and
Tasks 17/18/22/30 referencing electron imports landed with named
imports per plan; this commit refactors them to default+destructure
without changing semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 31 of the slice plan. Diff list of media/ subdirectories
against current note ids (SELECT id FROM notes); deletes
directories whose noteId is no longer referenced. Runs once on
startup from main; returns { removed } so logger captures the
count. Covers the 'media delete failure leaves orphan' branch
of spec §6.1 row 8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 30 of the slice plan. Replaces the Task 2 placeholder main
entry with the final whenReady wiring:
- profile path resolution + better-sqlite3 open + migrations.
- repo / store / continuity / intent / health constructed
against the open db.
- LocalOllamaProvider reads INKLING_OLLAMA_ENDPOINT for LAN
dogfood, falls back to localhost:11434 otherwise.
- AiWorker registers an onUpdate that fans note:updated through
pushNoteUpdated(getInboxWindow, note).
- NotificationService is plumbed to electron.Notification with
isSupported gating; CaptureService gets the worker.enqueue +
notify.celebrate hooks.
- IPC bindings for both capture and inbox surfaces.
- HotkeyService registers Ctrl/Cmd+Shift+J -> showQuickCapture;
failure logged but not fatal.
- Tray menu '구출한 메모 보기' / '기억 구출하기' / '종료'
with click-to-show-inbox.
- worker.loadFromDb() resumes pending jobs at startup.
- MediaGc runs once and logs the count of removed orphan dirs.
- Two logger.info(..., {...obj} as Record<string, unknown>)
casts at the health and gc result sites to satisfy the
index-signature requirement on logger meta — the result
objects (HealthResult, {removed: number}) are assignment-
compatible with Record<string, unknown> at runtime but TS
refuses without the spread + cast.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 29 of the slice plan. Subscribes to ollamaStatus from the
inbox store and renders one of two messages: 'ollama pull
gemma4:e4b 실행 후 앱을 재시작해주세요.' when the model isn't
installed (status.reason includes 'not installed'), or
'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.' for
any other unhealthy state. Capture continues regardless.
HealthChecker.ts itself was pulled forward in Task 21 so its
import would resolve from inboxApi.ts at compile time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 28 of the slice plan. Renders the green '🌱 흐름을 다시
이어갑니다' banner only when the parent says show=true
(continuity.showRecoveryToast && !dismissedToday). Click ✕
calls onDismiss which persists today's date in localStorage
via recoveryToast.ts, suppressing it for the rest of the KST
day even after a reload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 27 of the slice plan. Surfaces 'meaning question' once
per note (gated by intent_prompted_at IS NULL on the backend
side; frontend just provides the input UX). intentPrompts.ts
holds the 4 rotating prompts plus a deterministic
pickIntentPrompt(noteId) (FNV-style 32-bit hash mod 4) so the
same note always gets the same question across reloads. Submit
calls setIntent and reports the typed text up; Skip calls
dismissIntent and reports null. 200-char cap matches repo-side
truncation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 26 of the slice plan. Click-to-edit primitive used for
title, summary, and intent. Blur commits via injected onSave;
Enter blurs (single-line); Escape cancels and reverts to
the prop value. Failed save shows a 1px red outline for 800ms
and restores the prior value (no error message — caller decides
visual feedback). singleLine prop swaps <input> for <textarea>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 25 of the slice plan. Single-card view of a note with
local optimistic state plus the IPC writes. Per-status branches:
- pending: 'Inkling이 정리하는 중…' headline, raw text auto-open.
- failed: '정리 보류 — 원문은 안전합니다' with hover tooltip.
- done: editable title + summary (each with the gray 'AI' badge
hidden as soon as *_edited_by_user flips), tag chips with
AI subscript, optional 💡 user_intent row, IntentBanner
surfaced exactly when intent_prompted_at is null.
Tag click removes (per spec), title/summary edits route through
inboxApi.updateAiFields (which sets the edited flag server-side),
intent edits use inboxApi.setIntent. Delete confirms with '이
기억을 버릴까요? 되돌릴 수 없습니다.' and routes through
CaptureService.deleteNote so media gets cleaned up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 24 of the slice plan. Subscribes to pendingCount in the
store and renders '🟡 Inkling이 정리하는 중: N건' when count > 0.
Hidden when zero so the layout doesn't reserve space.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 23 of the slice plan. Reads continuity from the zustand
store and renders one of three states: '이번 주 한 줄이면
시작입니다' (0 notes), '이번 주 N/7' (in-progress), or
'이번 주 7/7 ✓ · 연속 K주 완성' (target hit, with K only when
> 0). No '실패'/'끊김' wording.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 22 of the slice plan. Wires the Inbox window's renderer:
- index.html (replaces the placeholder shipped in Task 2) with
the full layout styles + module script.
- api.ts re-exports window.inkling.inbox as a typed InboxApi.
- recoveryToast.ts persists per-day toast dismissal in
localStorage (KST date key) so the banner never re-fires
after the user closes it.
- store.ts: zustand store with notes, continuity, pendingCount,
ollamaStatus, loadInitial(), refreshMeta(), upsert/remove.
- main.tsx mounts <App />.
- App.tsx orchestrates loadInitial + onNoteUpdated subscription
+ window-focus refresh, renders header / banners / note list.
- 7 component stubs (NoteCard / EditableField / IntentBanner /
RecoveryToast / ContinuityBadge / PendingBanner / OllamaBanner)
so the shell typechecks today; Tasks 23-28 swap each in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 21 of the slice plan. registerInboxApi binds every
InboxApi method on the main side: inbox:list (paginated),
inbox:updateAi (delegates to NoteRepository.updateUserAiFields
which flips the *_edited_by_user flags), inbox:delete (routes
through CaptureService so the media dir gets cleaned up),
inbox:setIntent / inbox:dismissIntent (route through
IntentService for input validation), inbox:continuity (Weekly
Continuity snapshot), inbox:pendingCount, inbox:ollamaStatus
(reads the cached HealthChecker.lastStatus()). pushNoteUpdated
helper is exported so AiWorker.onUpdate (wired in Task 30) can
fan note:updated events to the inbox renderer.
Plan deviation: HealthChecker.ts was pulled forward from
Task 29 because Task 21 imports it at compile time. The class
is small and final; Task 29 commit only ships OllamaBanner.tsx.
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 19 of the slice plan. Frameless dark card with:
- placeholder "지금 머릿속에 있는 것 한 줄. 정리는 나중입니다."
- hint "Ctrl+Enter 구출 · Esc 취소 · 이미지 붙여넣기"
- Ctrl/Cmd+Enter to submit (window.inkling.capture.submit then
hide), Esc to cancel (with "이 한 줄을 흘려보낼까요?" confirm
when text > 5 chars)
- clipboard image paste -> thumbnail strip with ArrayBuffer
retained for submit
- fallback "저장에 실패했습니다. 다시 시도해주세요." in-card on
IPC error (window stays open with content preserved)
api.ts wraps window.inkling.capture as the typed CaptureApi.
main.tsx mounts <App /> on #root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 18 of the slice plan. Lazy-creates a 640x280 alwaysOnTop
frameless window centered on the primary display's work area
(1/3 from the top). skipTaskbar to keep it out of the alt-tab
list. Auto-hides on blur so capturing-then-clicking-elsewhere
dismisses cleanly. Singleton pattern; show/hide rather than
recreate to keep the <100ms hotkey latency target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 17 of the slice plan. register() pre-checks
globalShortcut.isRegistered to detect collisions with other
apps (Failure #1 in spec §6.1) and reports the conflict in
the return object so the OllamaBanner-style failure surface
in Task 29 can report it. unregisterAll() runs on app quit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>