Files
inkling/docs/superpowers/plans/2026-04-24-inkling-vertical-slice.md
altair823 4b16b873c6 feat(main): add Electron entry + Inbox window shell
Task 2 of the slice plan. Creates the minimal Electron main
process: app entry that opens an Inbox BrowserWindow on
whenReady, an inboxWindow module that handles show/hide/close-
to-tray semantics, an HTML placeholder renderer ("Inkling Inbox
(renderer pending)"), and the minimum @shared/types augmentation
for app.isQuitting (Task 3 expands this file).

Plan tsconfig template adjustment: TypeScript 6 deprecates
baseUrl. Dropped it and made paths entries explicitly relative
("./src/...") so they resolve from tsconfig.json's directory.
Updated both the in-repo tsconfig.json and Task 1 Step 3 in the
plan to match.

Verification: `npm run typecheck` exits 0. Full `npm run dev`
sanity check is deferred until Task 3 (preload) and Task 19
(quickcapture HTML) land — electron-vite.config.ts wires both
entries that don't yet exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:58:59 +09:00

121 KiB

Inkling Vertical Slice Implementation Plan (v0.2)

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.

Plan version: v0.4 (gemma4:e4b standard tier, downsized from 26b). Updated 2026-04-25.

Goal: Implement Inkling Vertical Slice v0.4 — a Windows/macOS Electron desktop app that captures text + pasted images via a global hotkey, persists them to SQLite, asynchronously generates Korean title/summary/tags via an Ollama provider (gemma4:e4b, endpoint configurable via INKLING_OLLAMA_ENDPOINT, defaults to localhost:11434), fires an OS native "post-submit reward" toast, displays results in an Inbox with AI-proposal labels, an inline "intent banner" (Strategy §2.2) after AI completes, a Weekly Continuity streak (7 notes/week, recovery-friendly copy), and a recovery toast on returning after a 7+ day gap.

Architecture: Electron main/renderer split with typed IPC. Main process hosts services (HotkeyService, CaptureService, NoteRepository, MediaStore, AiWorker, ContinuityService, NotificationService, IntentService) around a single better-sqlite3 database per profile. Renderer is React + Zustand with two windows (Inbox, QuickCapture). AI calls go through an InferenceProvider interface whose only slice implementation is LocalOllamaProvider (HTTP JSON; endpoint from INKLING_OLLAMA_ENDPOINT env var with localhost:11434 fallback; model gemma4:e4b).

Tech Stack: Node.js 24.15.0 LTS · Electron 41 · electron-vite 2 · React 19 · TypeScript 6 · Zustand 5 · better-sqlite3 12 · zod 4 · undici 8 (HTTP mocking) · Vitest 4 · @playwright/test 1.59 · electron-log 5.

Spec reference: docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md (v0.2) Strategy reference: docs/superpowers/strategy/strategy.md


File Structure

inkling/
├── .nvmrc                               # already exists (24.15.0)
├── package.json                         # Task 1
├── tsconfig.json                        # Task 1
├── tsconfig.node.json                   # Task 1
├── electron.vite.config.ts              # Task 1
├── vitest.config.ts                     # Task 1
├── playwright.config.ts                 # Task 32
├── .gitignore                           # Task 1
├── src/
│   ├── shared/
│   │   ├── types.ts                     # Task 3  shared IPC/domain types
│   │   └── intentPrompts.ts             # Task 27 rotating prompt strings
│   ├── preload/
│   │   └── index.ts                     # Task 3  contextBridge
│   ├── main/
│   │   ├── index.ts                     # Task 2 + Task 30 final wiring
│   │   ├── logger.ts                    # Task 4
│   │   ├── paths.ts                     # Task 5
│   │   ├── windows/
│   │   │   ├── inboxWindow.ts           # Task 2
│   │   │   └── quickCaptureWindow.ts    # Task 18
│   │   ├── db/
│   │   │   ├── index.ts                 # Task 6
│   │   │   └── migrations/
│   │   │       ├── index.ts             # Task 6
│   │   │       └── m001_initial.ts      # Task 6
│   │   ├── repository/
│   │   │   └── NoteRepository.ts        # Task 7
│   │   ├── services/
│   │   │   ├── MediaStore.ts            # Task 8
│   │   │   ├── ContinuityService.ts     # Task 9
│   │   │   ├── CaptureService.ts        # Task 14
│   │   │   ├── NotificationService.ts   # Task 15
│   │   │   ├── HotkeyService.ts         # Task 17
│   │   │   ├── IntentService.ts         # Task 20
│   │   │   ├── HealthChecker.ts         # Task 29
│   │   │   └── MediaGc.ts               # Task 31
│   │   ├── ai/
│   │   │   ├── schema.ts                # Task 10
│   │   │   ├── InferenceProvider.ts     # Task 11
│   │   │   ├── LocalOllamaProvider.ts   # Task 12
│   │   │   ├── prompt.ts                # Task 10
│   │   │   └── AiWorker.ts              # Task 13
│   │   ├── ipc/
│   │   │   ├── captureApi.ts            # Task 16
│   │   │   └── inboxApi.ts              # Task 21
│   │   └── tray.ts                      # Task 30
│   └── renderer/
│       ├── inbox/
│       │   ├── index.html               # Task 22
│       │   ├── main.tsx                 # Task 22
│       │   ├── App.tsx                  # Task 22
│       │   ├── store.ts                 # Task 22
│       │   ├── api.ts                   # Task 22
│       │   ├── recoveryToast.ts         # Task 22 dismiss persistence helper
│       │   └── components/
│       │       ├── NoteCard.tsx         # Task 25
│       │       ├── EditableField.tsx    # Task 26
│       │       ├── IntentBanner.tsx     # Task 27
│       │       ├── RecoveryToast.tsx    # Task 28
│       │       ├── ContinuityBadge.tsx  # Task 23
│       │       ├── PendingBanner.tsx    # Task 24
│       │       └── OllamaBanner.tsx     # Task 29
│       └── quickcapture/
│           ├── index.html               # Task 19
│           ├── main.tsx                 # Task 19
│           ├── App.tsx                  # Task 19
│           └── api.ts                   # Task 19
└── tests/
    ├── unit/
    │   ├── migrations.test.ts           # Task 6
    │   ├── NoteRepository.test.ts       # Task 7
    │   ├── MediaStore.test.ts           # Task 8
    │   ├── ContinuityService.test.ts    # Task 9
    │   ├── ai-schema.test.ts            # Task 10
    │   ├── LocalOllamaProvider.test.ts  # Task 12
    │   ├── AiWorker.test.ts             # Task 13
    │   ├── CaptureService.test.ts       # Task 14
    │   ├── NotificationService.test.ts  # Task 15
    │   └── IntentService.test.ts        # Task 20
    ├── integration/
    │   └── ollama-golden.test.ts        # Task 12
    └── e2e/
        └── smoke.spec.ts                # Task 32

Task 1: Bootstrap project

Files:

  • Create: package.json, tsconfig.json, tsconfig.node.json, electron.vite.config.ts, vitest.config.ts, .gitignore

  • Step 1: Initialize package.json

{
  "name": "inkling",
  "version": "0.2.0",
  "private": true,
  "type": "module",
  "main": "out/main/index.js",
  "scripts": {
    "dev": "electron-vite dev",
    "build": "electron-vite build",
    "start": "electron-vite preview",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:integration": "INKLING_INTEGRATION=1 vitest run tests/integration",
    "test:e2e": "playwright test",
    "typecheck": "tsc --noEmit"
  }
}
  • Step 2: Install dependencies
npm install --save-exact \
  electron@41.3.0 \
  react@19.2.5 react-dom@19.2.5 \
  zustand@5.0.12 \
  better-sqlite3@12.9.0 \
  zod@4.3.6 \
  electron-log@5.2.0 \
  uuid@11.0.3

npm install --save-exact --save-dev \
  typescript@6.0.3 \
  @types/node@24.0.0 \
  @types/react@19.0.0 @types/react-dom@19.0.0 \
  @types/better-sqlite3@7.6.11 \
  electron-vite@5.0.0 \
  vite@7.3.2 \
  @vitejs/plugin-react@5.1.4 \
  vitest@4.1.5 \
  undici@8.1.0 \
  @playwright/test@1.59.1

If a version is not yet published, run npm view <pkg> version and use the latest stable; update spec §7.2 in the same PR.

Resolution log (2026-04-25 install): v0.4 had electron-vite@2.3.0 (peer vite@^4||^5) conflicting with vite@6.0.3. Resolved by promoting the build-tool chain to electron-vite@5.0.0 + vite@7.3.2 + @vitejs/plugin-react@5.1.4 (all share vite@^7 as the compatible overlap; vitest@4.1.5 also accepts vite@7). @types/uuid line removed: uuid@11 ships its own type definitions, so @types/uuid@11.0.0 is a deprecated stub.

  • Step 3: tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noImplicitOverride": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "types": ["node"],
    "paths": {
      "@shared/*": ["./src/shared/*"],
      "@main/*": ["./src/main/*"]
    }
  },
  "include": ["src/**/*", "tests/**/*"],
  "exclude": ["node_modules", "out", "dist"]
}
  • Step 4: tsconfig.node.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["electron.vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
}
  • Step 5: electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';

export default defineConfig({
  main: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        input: { index: resolve(__dirname, 'src/main/index.ts') }
      }
    },
    resolve: {
      alias: {
        '@shared': resolve(__dirname, 'src/shared'),
        '@main': resolve(__dirname, 'src/main')
      }
    }
  },
  preload: {
    plugins: [externalizeDepsPlugin()],
    build: {
      rollupOptions: {
        input: { index: resolve(__dirname, 'src/preload/index.ts') }
      }
    },
    resolve: { alias: { '@shared': resolve(__dirname, 'src/shared') } }
  },
  renderer: {
    plugins: [react()],
    resolve: { alias: { '@shared': resolve(__dirname, 'src/shared') } },
    build: {
      rollupOptions: {
        input: {
          inbox: resolve(__dirname, 'src/renderer/inbox/index.html'),
          quickcapture: resolve(__dirname, 'src/renderer/quickcapture/index.html')
        }
      }
    }
  }
});
  • Step 6: vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';

export default defineConfig({
  test: {
    environment: 'node',
    globals: false,
    include: ['tests/unit/**/*.test.ts'],
    exclude: ['tests/integration/**', 'tests/e2e/**'],
    coverage: { reporter: ['text', 'html'] }
  },
  resolve: {
    alias: {
      '@shared': resolve(__dirname, 'src/shared'),
      '@main': resolve(__dirname, 'src/main')
    }
  }
});
  • Step 7: .gitignore
node_modules/
out/
dist/
*.log
.vite/
.DS_Store
coverage/
playwright-report/
test-results/
  • Step 8: Verify
npx tsc --version
npx vitest --version
npx electron --version

Expected: TypeScript 6.x, Vitest 4.x, Electron 41.x.

  • Step 9: Commit
git add package.json package-lock.json tsconfig.json tsconfig.node.json electron.vite.config.ts vitest.config.ts .gitignore
git commit -m "chore: bootstrap inkling project (v0.2 deps)"

Task 2: Electron main entry + Inbox window shell

Files: Create src/main/index.ts, src/main/windows/inboxWindow.ts, src/renderer/inbox/index.html

  • Step 1: Inbox HTML placeholder

src/renderer/inbox/index.html:

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
    <title>Inkling</title>
  </head>
  <body>
    <div id="root"></div>
    <script>document.getElementById('root').textContent = 'Inkling Inbox (renderer pending)';</script>
  </body>
</html>

(Task 22 restores the real module script.)

  • Step 2: inboxWindow.ts
import { BrowserWindow, app } from 'electron';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

let inboxWindow: BrowserWindow | null = null;
const __dirname = fileURLToPath(new URL('.', import.meta.url));

export function getInboxWindow(): BrowserWindow | null {
  return inboxWindow;
}

export function createInboxWindow(): BrowserWindow {
  if (inboxWindow && !inboxWindow.isDestroyed()) {
    inboxWindow.show();
    inboxWindow.focus();
    return inboxWindow;
  }

  inboxWindow = new BrowserWindow({
    width: 900,
    height: 720,
    show: false,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: false
    }
  });

  if (process.env.ELECTRON_RENDERER_URL) {
    inboxWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}/inbox/index.html`);
  } else {
    inboxWindow.loadFile(join(__dirname, '../renderer/inbox/index.html'));
  }

  inboxWindow.on('close', (e) => {
    if (!app.isQuitting) {
      e.preventDefault();
      inboxWindow?.hide();
    }
  });

  inboxWindow.once('ready-to-show', () => inboxWindow?.show());
  return inboxWindow;
}
  • Step 3: src/shared/types.ts minimum (Task 3 expands it)
declare global {
  namespace Electron {
    interface App { isQuitting?: boolean; }
  }
}
export {};
  • Step 4: src/main/index.ts
import { app, BrowserWindow } from 'electron';
import '@shared/types';
import { createInboxWindow } from './windows/inboxWindow.js';

app.whenReady().then(() => {
  createInboxWindow();
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
  });
});

app.on('before-quit', () => { app.isQuitting = true; });
  • Step 5: Verify dev start

npm run dev → window shows placeholder; close X hides; Ctrl+C exits.

  • Step 6: Commit
git add src/main/index.ts src/main/windows/inboxWindow.ts src/renderer/inbox/index.html src/shared/types.ts
git commit -m "feat(main): add Electron entry + Inbox window shell"

Task 3: Preload typed bridge with v0.2 types

Files: Create src/preload/index.ts, modify src/shared/types.ts

  • Step 1: Replace src/shared/types.ts
declare global {
  namespace Electron { interface App { isQuitting?: boolean; } }
  interface Window { inkling: InklingApi; }
}

export interface NoteMedia {
  id: string;
  kind: 'image';
  relPath: string;
  mime: string;
  bytes: number;
}

export type AiStatus = 'pending' | 'done' | 'failed';

export interface NoteTag {
  name: string;
  source: 'ai' | 'user';
}

export interface Note {
  id: string;
  rawText: string;
  aiTitle: string | null;
  aiSummary: string | null;
  aiStatus: AiStatus;
  aiError: string | null;
  aiProvider: string | null;
  aiGeneratedAt: string | null;
  titleEditedByUser: boolean;
  summaryEditedByUser: boolean;
  userIntent: string | null;
  intentPromptedAt: string | null;
  createdAt: string;
  updatedAt: string;
  tags: NoteTag[];
  media: NoteMedia[];
}

export interface WeeklyContinuity {
  weekStart: string;                 // ISO date (KST 월요일)
  weekCount: number;
  weekTarget: number;                // 7
  consecutiveCompleteWeeks: number;
  showRecoveryToast: boolean;
  lastNoteAt: string | null;
}

export interface CaptureApi {
  submit(payload: { text: string; images: ArrayBuffer[] }): Promise<{ noteId: string }>;
  hide(): void;
}

export interface InboxApi {
  listNotes(opts: { limit: number; cursor?: string }): Promise<Note[]>;
  updateAiFields(
    noteId: string,
    fields: { title?: string; summary?: string; tags?: string[] }
  ): Promise<void>;
  deleteNote(noteId: string): Promise<void>;
  setIntent(noteId: string, text: string): Promise<void>;
  dismissIntent(noteId: string): Promise<void>;
  getContinuity(): Promise<WeeklyContinuity>;
  getPendingCount(): Promise<number>;
  getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>;
  onNoteUpdated(cb: (note: Note) => void): () => void;
}

export interface InklingApi {
  capture: CaptureApi;
  inbox: InboxApi;
}

export {};
  • Step 2: src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { InklingApi, Note } from '@shared/types';

const api: InklingApi = {
  capture: {
    submit: (payload) => ipcRenderer.invoke('capture:submit', payload),
    hide: () => ipcRenderer.send('capture:hide')
  },
  inbox: {
    listNotes: (opts) => ipcRenderer.invoke('inbox:list', opts),
    updateAiFields: (noteId, fields) =>
      ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
    deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
    setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }),
    dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId),
    getContinuity: () => ipcRenderer.invoke('inbox:continuity'),
    getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'),
    getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'),
    onNoteUpdated: (cb) => {
      const listener = (_e: unknown, note: Note) => cb(note);
      ipcRenderer.on('note:updated', listener);
      return () => ipcRenderer.off('note:updated', listener);
    }
  }
};

contextBridge.exposeInMainWorld('inkling', api);
  • Step 3: Typecheck and commit
npm run typecheck
git add src/preload/index.ts src/shared/types.ts
git commit -m "feat(preload): expose typed InklingApi (v0.2: intent + continuity)"

Task 4: Logger with PII protection

Files: Create src/main/logger.ts, modify src/main/index.ts

  • Step 1: logger.ts
import log from 'electron-log/main';
import { app } from 'electron';
import { join } from 'node:path';
import { createHash } from 'node:crypto';

let initialized = false;

export function initLogger(): void {
  if (initialized) return;
  const logDir = join(app.getPath('userData'), 'Inkling', 'logs');
  log.transports.file.resolvePathFn = () =>
    join(logDir, `main-${new Date().toISOString().slice(0, 10)}.log`);
  log.transports.file.maxSize = 5 * 1024 * 1024;
  log.transports.file.level = 'info';
  log.transports.console.level = process.env.INKLING_DEBUG === '1' ? 'debug' : 'info';
  initialized = true;
}

export function hashPrefix(text: string): string {
  return createHash('sha256').update(text).digest('hex').slice(0, 8);
}

export const logger = {
  info: (msg: string, meta?: Record<string, unknown>) => log.info(msg, meta ?? {}),
  warn: (msg: string, meta?: Record<string, unknown>) => log.warn(msg, meta ?? {}),
  error: (msg: string, meta?: Record<string, unknown>) => log.error(msg, meta ?? {}),
  debug: (msg: string, meta?: Record<string, unknown>) => log.debug(msg, meta ?? {})
};
  • Step 2: Wire in src/main/index.ts

Add to imports:

import { initLogger, logger } from './logger.js';

Insert at top of whenReady body:

  initLogger();
  logger.info('app.start', { platform: process.platform, version: app.getVersion() });
  • Step 3: Verify and commit

npm run dev → check {userData}/Inkling/logs/main-YYYY-MM-DD.log has app.start entry.

git add src/main/logger.ts src/main/index.ts
git commit -m "feat(logger): add electron-log with PII-safe helpers"

Task 5: Paths utility

Files: Create src/main/paths.ts

  • Step 1: Implement
import { app } from 'electron';
import { join } from 'node:path';
import { mkdirSync } from 'node:fs';

export interface ProfilePaths {
  profileDir: string;
  dbFile: string;
  mediaDir: string;
}

export function resolveProfilePaths(profile = 'default'): ProfilePaths {
  const root = join(app.getPath('userData'), 'Inkling', 'profiles', profile);
  const p: ProfilePaths = {
    profileDir: root,
    dbFile: join(root, 'inkling.sqlite'),
    mediaDir: join(root, 'media')
  };
  mkdirSync(p.mediaDir, { recursive: true });
  return p;
}
  • Step 2: Commit
git add src/main/paths.ts
git commit -m "feat(paths): per-profile directory resolver"

Task 6: DB initialization + v1 migration with intent + edited columns

Files: Create src/main/db/index.ts, src/main/db/migrations/index.ts, src/main/db/migrations/m001_initial.ts, tests/unit/migrations.test.ts

  • Step 1: m001_initial.ts
import type Database from 'better-sqlite3';

export const version = 1;

export function up(db: Database.Database): void {
  db.exec(`
    CREATE TABLE notes (
      id                       TEXT PRIMARY KEY,
      raw_text                 TEXT NOT NULL,
      ai_title                 TEXT,
      ai_summary               TEXT,
      ai_status                TEXT NOT NULL
                               CHECK (ai_status IN ('pending','done','failed')),
      ai_error                 TEXT,
      ai_provider              TEXT,
      ai_generated_at          TEXT,
      title_edited_by_user     INTEGER NOT NULL DEFAULT 0
                               CHECK (title_edited_by_user IN (0,1)),
      summary_edited_by_user   INTEGER NOT NULL DEFAULT 0
                               CHECK (summary_edited_by_user IN (0,1)),
      user_intent              TEXT,
      intent_prompted_at       TEXT,
      created_at               TEXT NOT NULL,
      updated_at               TEXT NOT NULL
    );
    CREATE INDEX idx_notes_created_at ON notes(created_at DESC);
    CREATE INDEX idx_notes_ai_status  ON notes(ai_status);

    CREATE TABLE tags (
      id    INTEGER PRIMARY KEY AUTOINCREMENT,
      name  TEXT NOT NULL UNIQUE COLLATE NOCASE
    );
    CREATE TABLE note_tags (
      note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
      tag_id  INTEGER NOT NULL REFERENCES tags(id),
      source  TEXT NOT NULL CHECK (source IN ('ai','user')),
      PRIMARY KEY (note_id, tag_id)
    );

    CREATE TABLE media (
      id         TEXT PRIMARY KEY,
      note_id    TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
      kind       TEXT NOT NULL CHECK (kind IN ('image')),
      rel_path   TEXT NOT NULL,
      mime       TEXT NOT NULL,
      bytes      INTEGER NOT NULL,
      created_at TEXT NOT NULL
    );
    CREATE INDEX idx_media_note_id ON media(note_id);

    CREATE TABLE pending_jobs (
      note_id     TEXT PRIMARY KEY REFERENCES notes(id) ON DELETE CASCADE,
      attempts    INTEGER NOT NULL DEFAULT 0,
      next_run_at TEXT NOT NULL,
      last_error  TEXT
    );
  `);
}
  • Step 2: migrations/index.ts
import type Database from 'better-sqlite3';
import * as m001 from './m001_initial.js';

const migrations = [m001];

export function runMigrations(db: Database.Database): void {
  const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
  const current = row.user_version ?? 0;
  for (const m of migrations) {
    if (m.version > current) {
      const tx = db.transaction(() => {
        m.up(db);
        db.pragma(`user_version = ${m.version}`);
      });
      tx();
    }
  }
}
  • Step 3: db/index.ts
import Database from 'better-sqlite3';
import { runMigrations } from './migrations/index.js';

export function openDb(dbFile: string): Database.Database {
  const db = new Database(dbFile);
  db.pragma('journal_mode = WAL');
  db.pragma('foreign_keys = ON');
  runMigrations(db);
  return db;
}
  • Step 4: tests/unit/migrations.test.ts
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';

describe('migrations', () => {
  it('creates schema at version 1 with intent + edited columns', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const ver = (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version;
    expect(ver).toBe(1);
    const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name);
    expect(cols).toEqual(
      expect.arrayContaining([
        'id', 'raw_text', 'ai_title', 'ai_summary', 'ai_status', 'ai_error',
        'ai_provider', 'ai_generated_at',
        'title_edited_by_user', 'summary_edited_by_user',
        'user_intent', 'intent_prompted_at',
        'created_at', 'updated_at'
      ])
    );
    db.close();
  });

  it('is idempotent', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    runMigrations(db);
    expect((db.prepare('PRAGMA user_version').get() as any).user_version).toBe(1);
    db.close();
  });
});
  • Step 5: Run + commit
npx vitest run tests/unit/migrations.test.ts
git add src/main/db/ tests/unit/migrations.test.ts
git commit -m "feat(db): v1 schema with user_intent + edited flags"

Task 7: NoteRepository with intent + edited flags

Files: Create src/main/repository/NoteRepository.ts, tests/unit/NoteRepository.test.ts

  • Step 1: Failing test for create + edited flag default 0
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';

function freshDb() {
  const db = new Database(':memory:');
  runMigrations(db);
  return db;
}

describe('NoteRepository', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  beforeEach(() => { db = freshDb(); repo = new NoteRepository(db); });

  it('create stores raw_text, defaults edited flags to 0, intent fields NULL, enqueues pending job', () => {
    const { id } = repo.create({ rawText: '회의 메모' });
    const note = repo.findById(id)!;
    expect(note.rawText).toBe('회의 메모');
    expect(note.titleEditedByUser).toBe(false);
    expect(note.summaryEditedByUser).toBe(false);
    expect(note.userIntent).toBeNull();
    expect(note.intentPromptedAt).toBeNull();
    expect(note.aiStatus).toBe('pending');
    const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id);
    expect(job).toBeDefined();
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/NoteRepository.test.ts → FAIL.

  • Step 3: Implement repository
import type Database from 'better-sqlite3';
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
import type { Note, NoteMedia, NoteTag } from '@shared/types';

export interface CreateNoteInput { rawText: string; }

export interface NewMediaRow {
  noteId: string;
  kind: 'image';
  relPath: string;
  mime: string;
  bytes: number;
}

export class NoteRepository {
  constructor(private db: Database.Database) {}

  create(input: CreateNoteInput): { id: string } {
    const id = uuidv7();
    const now = new Date().toISOString();
    const tx = this.db.transaction(() => {
      this.db
        .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
                  VALUES (?, ?, 'pending', ?, ?)`)
        .run(id, input.rawText, now, now);
      this.db
        .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
                  VALUES (?, 0, ?)`)
        .run(id, now);
    });
    tx();
    return { id };
  }

  insertMedia(rows: NewMediaRow[]): void {
    if (rows.length === 0) return;
    const now = new Date().toISOString();
    const stmt = this.db.prepare(
      `INSERT INTO media (id, note_id, kind, rel_path, mime, bytes, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?)`
    );
    const tx = this.db.transaction(() => {
      for (const r of rows) {
        stmt.run(uuidv4(), r.noteId, r.kind, r.relPath, r.mime, r.bytes, now);
      }
    });
    tx();
  }

  findById(id: string): Note | null {
    const row = this.db.prepare('SELECT * FROM notes WHERE id=?').get(id) as any;
    if (!row) return null;
    return this.hydrate(row);
  }

  list(opts: { limit: number; cursor?: string }): Note[] {
    const limit = Math.max(1, Math.min(200, opts.limit));
    const rows = opts.cursor
      ? (this.db
          .prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC LIMIT ?`)
          .all(opts.cursor, limit) as any[])
      : (this.db
          .prepare(`SELECT * FROM notes ORDER BY created_at DESC LIMIT ?`)
          .all(limit) as any[]);
    return rows.map((r) => this.hydrate(r));
  }

  updateAiResult(
    id: string,
    result: { title: string; summary: string; tags: string[]; provider: string }
  ): void {
    const now = new Date().toISOString();
    const tx = this.db.transaction(() => {
      // Only overwrite ai_title/summary if user hasn't edited them
      this.db
        .prepare(
          `UPDATE notes
              SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
                  ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
                  ai_status = 'done',
                  ai_provider = ?,
                  ai_generated_at = ?,
                  ai_error = NULL,
                  updated_at = ?
            WHERE id = ?`
        )
        .run(result.title, result.summary, result.provider, now, now, id);
      this.db.prepare(`DELETE FROM note_tags WHERE note_id=? AND source='ai'`).run(id);
      const getOrInsertTag = this.db.prepare(
        `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
      );
      const linkTag = this.db.prepare(
        `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
      );
      for (const t of result.tags) {
        const tagRow = getOrInsertTag.get(t) as { id: number };
        linkTag.run(id, tagRow.id);
      }
      this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
    });
    tx();
  }

  markAiFailed(id: string, error: string): void {
    const now = new Date().toISOString();
    const tx = this.db.transaction(() => {
      this.db
        .prepare(`UPDATE notes SET ai_status='failed', ai_error=?, updated_at=? WHERE id=?`)
        .run(error.slice(0, 500), now, id);
      this.db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
    });
    tx();
  }

  updateUserAiFields(
    id: string,
    fields: { title?: string; summary?: string; tags?: string[] }
  ): void {
    const now = new Date().toISOString();
    const tx = this.db.transaction(() => {
      const updates: string[] = [];
      const params: unknown[] = [];
      if (fields.title !== undefined) {
        updates.push('ai_title=?');
        updates.push('title_edited_by_user=1');
        params.push(fields.title);
      }
      if (fields.summary !== undefined) {
        updates.push('ai_summary=?');
        updates.push('summary_edited_by_user=1');
        params.push(fields.summary);
      }
      if (updates.length > 0) {
        updates.push('updated_at=?');
        params.push(now);
        params.push(id);
        this.db.prepare(`UPDATE notes SET ${updates.join(', ')} WHERE id=?`).run(...params);
      }
      if (fields.tags !== undefined) {
        this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(id);
        const getOrInsert = this.db.prepare(
          `INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
        );
        const link = this.db.prepare(
          `INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
        );
        for (const t of fields.tags) {
          const row = getOrInsert.get(t) as { id: number };
          link.run(id, row.id);
        }
      }
    });
    tx();
  }

  setIntent(id: string, text: string): void {
    const now = new Date().toISOString();
    this.db
      .prepare(
        `UPDATE notes
            SET user_intent = ?,
                intent_prompted_at = COALESCE(intent_prompted_at, ?),
                updated_at = ?
          WHERE id = ?`
      )
      .run(text.slice(0, 200), now, now, id);
  }

  dismissIntent(id: string): void {
    const now = new Date().toISOString();
    this.db
      .prepare(
        `UPDATE notes
            SET intent_prompted_at = COALESCE(intent_prompted_at, ?),
                updated_at = ?
          WHERE id = ?`
      )
      .run(now, now, id);
  }

  delete(id: string): void {
    this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
  }

  getPendingCount(): number {
    const row = this.db
      .prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`)
      .get() as { c: number };
    return row.c;
  }

  getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
    const rows = this.db
      .prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
      .all() as any[];
    return rows.map((r) => ({
      noteId: r.note_id,
      attempts: r.attempts,
      nextRunAt: r.next_run_at
    }));
  }

  incrementJobAttempt(noteId: string, nextRunAt: string, lastError: string): void {
    this.db
      .prepare(
        `UPDATE pending_jobs
            SET attempts = attempts + 1,
                next_run_at = ?,
                last_error = ?
          WHERE note_id = ?`
      )
      .run(nextRunAt, lastError.slice(0, 500), noteId);
  }

  private hydrate(row: any): Note {
    const tags = this.db
      .prepare(
        `SELECT t.name, nt.source
           FROM note_tags nt JOIN tags t ON t.id = nt.tag_id
          WHERE nt.note_id = ? ORDER BY t.name`
      )
      .all(row.id) as Array<{ name: string; source: 'ai' | 'user' }>;
    const media = this.db
      .prepare(
        `SELECT id, kind, rel_path as relPath, mime, bytes FROM media WHERE note_id=?`
      )
      .all(row.id) as NoteMedia[];
    return {
      id: row.id,
      rawText: row.raw_text,
      aiTitle: row.ai_title,
      aiSummary: row.ai_summary,
      aiStatus: row.ai_status,
      aiError: row.ai_error,
      aiProvider: row.ai_provider,
      aiGeneratedAt: row.ai_generated_at,
      titleEditedByUser: row.title_edited_by_user === 1,
      summaryEditedByUser: row.summary_edited_by_user === 1,
      userIntent: row.user_intent,
      intentPromptedAt: row.intent_prompted_at,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
      tags: tags as NoteTag[],
      media
    };
  }
}
  • Step 4: Verify first test passes

npx vitest run tests/unit/NoteRepository.test.ts → first test passes.

  • Step 5: Append remaining tests
  it('updateAiResult does not overwrite user-edited title/summary', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p' });
    repo.updateUserAiFields(id, { title: '내 제목', summary: '내 요약\n둘\n셋' });
    repo.updateAiResult(id, { title: 'AI 제목 2', summary: 'x\ny\nz', tags: [], provider: 'p' });
    const note = repo.findById(id)!;
    expect(note.aiTitle).toBe('내 제목');
    expect(note.aiSummary).toBe('내 요약\n둘\n셋');
    expect(note.titleEditedByUser).toBe(true);
    expect(note.summaryEditedByUser).toBe(true);
  });

  it('updateAiResult marks done, replaces ai tags, removes pending job', () => {
    const { id } = repo.create({ rawText: '원문' });
    repo.updateAiResult(id, {
      title: '제목', summary: '1줄\n2줄\n3줄',
      tags: ['api-timeout', 'meeting'], provider: 'local-ollama/gemma4:e4b'
    });
    const note = repo.findById(id)!;
    expect(note.aiStatus).toBe('done');
    expect(note.tags.map((t) => t.name).sort()).toEqual(['api-timeout', 'meeting']);
    expect(note.tags.every((t) => t.source === 'ai')).toBe(true);
    expect(db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id)).toBeUndefined();
  });

  it('markAiFailed truncates and clears pending job', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.markAiFailed(id, 'E'.repeat(600));
    const note = repo.findById(id)!;
    expect(note.aiStatus).toBe('failed');
    expect(note.aiError?.length).toBe(500);
  });

  it('updateUserAiFields replaces user-sourced tags', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.updateAiResult(id, { title: 'ai', summary: 'a\nb\nc', tags: ['ai-tag'], provider: 'p' });
    repo.updateUserAiFields(id, { tags: ['user-tag'] });
    const note = repo.findById(id)!;
    expect(note.tags).toEqual([{ name: 'user-tag', source: 'user' }]);
  });

  it('setIntent stores user_intent, sets intent_prompted_at first time, preserves on subsequent', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.setIntent(id, '내일의 나에게');
    const a = repo.findById(id)!;
    expect(a.userIntent).toBe('내일의 나에게');
    expect(a.intentPromptedAt).not.toBeNull();
    const firstStamp = a.intentPromptedAt!;
    repo.setIntent(id, '수정');
    const b = repo.findById(id)!;
    expect(b.userIntent).toBe('수정');
    expect(b.intentPromptedAt).toBe(firstStamp);
  });

  it('dismissIntent stamps intent_prompted_at without setting user_intent', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.dismissIntent(id);
    const note = repo.findById(id)!;
    expect(note.userIntent).toBeNull();
    expect(note.intentPromptedAt).not.toBeNull();
  });

  it('setIntent truncates to 200 chars', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.setIntent(id, 'X'.repeat(300));
    expect(repo.findById(id)!.userIntent?.length).toBe(200);
  });

  it('delete cascades note_tags, media, pending_jobs', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.insertMedia([{ noteId: id, kind: 'image', relPath: 'm/x.png', mime: 'image/png', bytes: 10 }]);
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['z'], provider: 'p' });
    repo.delete(id);
    expect(repo.findById(id)).toBeNull();
    expect(db.prepare('SELECT COUNT(*) AS c FROM media').get()).toEqual({ c: 0 });
    expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags').get()).toEqual({ c: 0 });
  });

  it('list returns notes in descending created_at', () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    expect(repo.list({ limit: 10 }).map((n) => n.id)).toEqual([b, a]);
  });

  it('getPendingCount counts pending notes', () => {
    repo.create({ rawText: 'a' });
    const { id } = repo.create({ rawText: 'b' });
    expect(repo.getPendingCount()).toBe(2);
    repo.markAiFailed(id, 'err');
    expect(repo.getPendingCount()).toBe(1);
  });

  it('incrementJobAttempt bumps attempts and stores last_error', () => {
    const { id } = repo.create({ rawText: 'x' });
    repo.incrementJobAttempt(id, new Date().toISOString(), 'boom');
    const row = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id) as any;
    expect(row.attempts).toBe(1);
    expect(row.last_error).toBe('boom');
  });
  • Step 6: Run + commit
npx vitest run tests/unit/NoteRepository.test.ts
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "feat(repo): NoteRepository with intent, edited flags, AI overwrite guard"

Task 8: MediaStore

Files: Create src/main/services/MediaStore.ts, tests/unit/MediaStore.test.ts

  • Step 1: Failing tests
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MediaStore } from '@main/services/MediaStore.js';

describe('MediaStore', () => {
  let tmp: string;
  let store: MediaStore;

  beforeEach(() => {
    tmp = mkdtempSync(join(tmpdir(), 'inkling-media-'));
    store = new MediaStore(tmp);
  });

  it('saves a png under media/{noteId}/', async () => {
    const bytes = Buffer.from('\x89PNG\r\n\x1a\n' + 'A'.repeat(100));
    const saved = await store.saveImage('note-123', bytes, 'image/png');
    expect(saved.relPath.startsWith('media/note-123/')).toBe(true);
    expect(saved.relPath.endsWith('.png')).toBe(true);
    expect(saved.bytes).toBe(bytes.length);
    expect(readFileSync(join(tmp, saved.relPath)).equals(bytes)).toBe(true);
  });

  it('saves multiple images with unique filenames', async () => {
    const bytes = Buffer.from('abc');
    const a = await store.saveImage('n', bytes, 'image/png');
    const b = await store.saveImage('n', bytes, 'image/png');
    expect(a.relPath).not.toBe(b.relPath);
  });

  it('deleteNoteDirectory removes the note dir', async () => {
    await store.saveImage('note-x', Buffer.from('abc'), 'image/png');
    await store.deleteNoteDirectory('note-x');
    expect(existsSync(join(tmp, 'media/note-x'))).toBe(false);
  });

  it('listNoteDirs returns dir names', async () => {
    await store.saveImage('alpha', Buffer.from('a'), 'image/png');
    await store.saveImage('beta', Buffer.from('b'), 'image/png');
    expect((await store.listNoteDirs()).sort()).toEqual(['alpha', 'beta']);
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/MediaStore.test.ts → FAIL.

  • Step 3: Implement
import { mkdir, writeFile, rm, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { v4 as uuidv4 } from 'uuid';

export interface SavedMedia {
  relPath: string;
  mime: string;
  bytes: number;
}

export class MediaStore {
  constructor(private profileDir: string) {}

  async saveImage(noteId: string, bytes: Buffer, mime: string): Promise<SavedMedia> {
    const dir = join(this.profileDir, 'media', noteId);
    await mkdir(dir, { recursive: true });
    const ext = mime === 'image/png' ? 'png' : mime === 'image/jpeg' ? 'jpg' : 'bin';
    const filename = `${uuidv4()}.${ext}`;
    await writeFile(join(dir, filename), bytes);
    return { relPath: `media/${noteId}/${filename}`, mime, bytes: bytes.length };
  }

  async deleteNoteDirectory(noteId: string): Promise<void> {
    await rm(join(this.profileDir, 'media', noteId), { recursive: true, force: true });
  }

  async listNoteDirs(): Promise<string[]> {
    const root = join(this.profileDir, 'media');
    try {
      const entries = await readdir(root, { withFileTypes: true });
      return entries.filter((e) => e.isDirectory()).map((e) => e.name);
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
      throw err;
    }
  }
}
  • Step 4: Run + commit
npx vitest run tests/unit/MediaStore.test.ts
git add src/main/services/MediaStore.ts tests/unit/MediaStore.test.ts
git commit -m "feat(media): MediaStore for image persistence and cleanup"

Task 9: ContinuityService (Weekly Continuity)

Files: Create src/main/services/ContinuityService.ts, tests/unit/ContinuityService.test.ts

  • Step 1: Failing tests
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { ContinuityService } from '@main/services/ContinuityService.js';

function dbWithDates(isoDates: string[]): Database.Database {
  const db = new Database(':memory:');
  runMigrations(db);
  const insert = db.prepare(
    `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
     VALUES (?, ?, 'pending', ?, ?)`
  );
  for (const [i, d] of isoDates.entries()) insert.run(`n${i}`, 'x', d, d);
  return db;
}

describe('ContinuityService', () => {
  it('empty db returns zero counts and no recovery toast', () => {
    const db = new Database(':memory:');
    runMigrations(db);
    const svc = new ContinuityService(db, () => new Date('2026-04-25T10:00:00+09:00'));
    const r = svc.get();
    expect(r.weekCount).toBe(0);
    expect(r.weekTarget).toBe(7);
    expect(r.consecutiveCompleteWeeks).toBe(0);
    expect(r.showRecoveryToast).toBe(false);
    expect(r.lastNoteAt).toBeNull();
  });

  it('counts notes in current KST week (월~일)', () => {
    // KST 2026-04-20 (월) ~ 2026-04-26 (일)
    const db = dbWithDates([
      '2026-04-20T01:00:00+09:00', // 월
      '2026-04-22T03:00:00+09:00', // 수
      '2026-04-25T22:00:00+09:00'  // 토 (조사 시점 직전)
    ]);
    const svc = new ContinuityService(db, () => new Date('2026-04-25T23:00:00+09:00'));
    const r = svc.get();
    expect(r.weekStart).toBe('2026-04-20');
    expect(r.weekCount).toBe(3);
  });

  function isoKst(year: number, month: number, day: number, hour = 10): string {
    const mm = String(month).padStart(2, '0');
    const dd = String(day).padStart(2, '0');
    const hh = String(hour).padStart(2, '0');
    return `${year}-${mm}-${dd}T${hh}:00:00+09:00`;
  }

  it('consecutiveCompleteWeeks counts weeks with >=7 notes ending immediately before current week', () => {
    const dates: string[] = [];
    // 2주 전 (2026-04-06 월 ~ 04-12 일): 7건
    for (let d = 6; d <= 12; d++) dates.push(isoKst(2026, 4, d));
    // 1주 전 (2026-04-13 월 ~ 04-19 일): 7건
    for (let d = 13; d <= 19; d++) dates.push(isoKst(2026, 4, d));
    // 이번 주 (2026-04-20 월 ~ 04-26 일): 2건 (미완성)
    dates.push(isoKst(2026, 4, 20));
    dates.push(isoKst(2026, 4, 21));
    const db = dbWithDates(dates);
    const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
    const r = svc.get();
    expect(r.consecutiveCompleteWeeks).toBe(2);
    expect(r.weekCount).toBe(2);
  });

  it('consecutiveCompleteWeeks includes current week if it already hit 7', () => {
    const dates: string[] = [];
    for (let d = 13; d <= 19; d++) dates.push(isoKst(2026, 4, d));
    for (let d = 20; d <= 26; d++) dates.push(isoKst(2026, 4, d));
    const db = dbWithDates(dates);
    const svc = new ContinuityService(db, () => new Date('2026-04-26T23:00:00+09:00'));
    const r = svc.get();
    expect(r.weekCount).toBe(7);
    expect(r.consecutiveCompleteWeeks).toBe(2);
  });

  it('showRecoveryToast=true when last note is >=7 days ago AND a fresh note exists today', () => {
    const dates = [
      '2026-04-15T10:00:00+09:00',   // 10일 전
      '2026-04-25T11:00:00+09:00'    // 오늘 (recovery moment)
    ];
    const db = dbWithDates(dates);
    const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
    expect(svc.get().showRecoveryToast).toBe(true);
  });

  it('showRecoveryToast=false when there are notes within last 7 days', () => {
    const db = dbWithDates([
      '2026-04-22T10:00:00+09:00',
      '2026-04-25T11:00:00+09:00'
    ]);
    const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
    expect(svc.get().showRecoveryToast).toBe(false);
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/ContinuityService.test.ts → FAIL.

  • Step 3: Implement
import type Database from 'better-sqlite3';
import type { WeeklyContinuity } from '@shared/types';

const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_TARGET = 7;
const RECOVERY_GAP_DAYS = 7;

function toKstDateKey(d: Date): string {
  const k = new Date(d.getTime() + KST_OFFSET_MS);
  return k.toISOString().slice(0, 10);
}

function kstMondayOf(d: Date): string {
  const k = new Date(d.getTime() + KST_OFFSET_MS);
  const dayIdx = (k.getUTCDay() + 6) % 7; // Mon=0..Sun=6
  k.setUTCDate(k.getUTCDate() - dayIdx);
  return k.toISOString().slice(0, 10);
}

function addDaysIso(iso: string, days: number): string {
  const d = new Date(iso + 'T00:00:00Z');
  d.setUTCDate(d.getUTCDate() + days);
  return d.toISOString().slice(0, 10);
}

export class ContinuityService {
  constructor(
    private db: Database.Database,
    private now: () => Date = () => new Date()
  ) {}

  get(): WeeklyContinuity {
    const rows = this.db
      .prepare(`SELECT created_at FROM notes ORDER BY created_at ASC`)
      .all() as Array<{ created_at: string }>;
    const dates = rows.map((r) => new Date(r.created_at));
    if (dates.length === 0) {
      return {
        weekStart: kstMondayOf(this.now()),
        weekCount: 0,
        weekTarget: WEEK_TARGET,
        consecutiveCompleteWeeks: 0,
        showRecoveryToast: false,
        lastNoteAt: null
      };
    }

    // Group by KST week (Mon-Sun)
    const byWeek = new Map<string, number>();
    for (const d of dates) {
      const wk = kstMondayOf(d);
      byWeek.set(wk, (byWeek.get(wk) ?? 0) + 1);
    }

    const currentWeek = kstMondayOf(this.now());
    const weekCount = byWeek.get(currentWeek) ?? 0;

    // consecutiveCompleteWeeks: walk backward from current week (or previous if current incomplete)
    let consecutive = 0;
    let cursor = weekCount >= WEEK_TARGET ? currentWeek : addDaysIso(currentWeek, -7);
    while ((byWeek.get(cursor) ?? 0) >= WEEK_TARGET) {
      consecutive += 1;
      cursor = addDaysIso(cursor, -7);
    }

    // Recovery toast: latest note today (KST) AND second-latest is >=7 days before that
    const last = dates[dates.length - 1]!;
    const todayKst = toKstDateKey(this.now());
    const lastIsToday = toKstDateKey(last) === todayKst;
    let showRecoveryToast = false;
    if (lastIsToday && dates.length >= 2) {
      const prev = dates[dates.length - 2]!;
      const gapMs = last.getTime() - prev.getTime();
      if (gapMs >= RECOVERY_GAP_DAYS * ONE_DAY_MS) showRecoveryToast = true;
    } else if (lastIsToday && dates.length === 1) {
      // First note ever: not a "recovery", it's a start
      showRecoveryToast = false;
    }

    return {
      weekStart: currentWeek,
      weekCount,
      weekTarget: WEEK_TARGET,
      consecutiveCompleteWeeks: consecutive,
      showRecoveryToast,
      lastNoteAt: last.toISOString()
    };
  }
}
  • Step 4: Run + commit
npx vitest run tests/unit/ContinuityService.test.ts
git add src/main/services/ContinuityService.ts tests/unit/ContinuityService.test.ts
git commit -m "feat(continuity): WeeklyContinuity service (7 notes/week, recovery detection)"

Task 10: AI schema + prompt template

Files: Create src/main/ai/schema.ts, src/main/ai/prompt.ts, tests/unit/ai-schema.test.ts

  • Step 1: Failing tests
import { describe, it, expect } from 'vitest';
import { parseAiResponse } from '@main/ai/schema.js';

describe('parseAiResponse', () => {
  it('accepts valid Korean title, 3-line summary, kebab tags', () => {
    const r = parseAiResponse({
      title: '회의 요약',
      summary: '첫 줄\n둘째 줄\n셋째 줄',
      tags: ['api-timeout', 'meeting']
    });
    expect(r.title).toBe('회의 요약');
    expect(r.summary.split('\n')).toHaveLength(3);
    expect(r.tags).toEqual(['api-timeout', 'meeting']);
  });

  it('rejects title without Korean', () => {
    expect(() =>
      parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] })
    ).toThrow(/korean/i);
  });

  it('pads short summary to 3 lines', () => {
    const r = parseAiResponse({ title: '제목', summary: '한 줄', tags: [] });
    expect(r.summary.split('\n')).toHaveLength(3);
  });

  it('compresses long summary to 3 lines', () => {
    const r = parseAiResponse({
      title: '제목', summary: 'a\nb\nc\nd\ne', tags: []
    });
    const lines = r.summary.split('\n');
    expect(lines).toHaveLength(3);
    expect(lines[2]).toBe('c d e');
  });

  it('filters invalid tags', () => {
    const r = parseAiResponse({
      title: '제목', summary: 'a\nb\nc',
      tags: ['good-tag', 'BadCase', 'has space', 'ok2', '']
    });
    expect(r.tags).toEqual(['good-tag', 'ok2']);
  });

  it('caps tags to 3', () => {
    const r = parseAiResponse({
      title: '제목', summary: 'a\nb\nc',
      tags: ['a', 'b', 'c', 'd', 'e']
    });
    expect(r.tags).toHaveLength(3);
  });

  it('rejects non-object input', () => {
    expect(() => parseAiResponse('nope')).toThrow();
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/ai-schema.test.ts → FAIL.

  • Step 3: Implement schema
import { z } from 'zod';

const KOREAN_REGEX = /[가-힣]/;
const KEBAB_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;

const RawResponseSchema = z.object({
  title: z.string().trim().min(1).max(200),
  summary: z.string().min(1),
  tags: z.array(z.string()).default([])
});

export interface AiResponse {
  title: string;
  summary: string;
  tags: string[];
}

function normalizeSummary(raw: string): string {
  const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
  if (lines.length === 0) throw new Error('summary is empty');
  if (lines.length === 3) return lines.join('\n');
  if (lines.length < 3) {
    while (lines.length < 3) lines.push('');
    return lines.join('\n');
  }
  const head = lines.slice(0, 2);
  const tail = lines.slice(2).join(' ');
  return [...head, tail].join('\n');
}

export function parseAiResponse(raw: unknown): AiResponse {
  const parsed = RawResponseSchema.parse(raw);
  if (!KOREAN_REGEX.test(parsed.title)) {
    throw new Error('title must contain Korean characters');
  }
  return {
    title: parsed.title.slice(0, 60),
    summary: normalizeSummary(parsed.summary),
    tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3)
  };
}
  • Step 4: Implement prompt

src/main/ai/prompt.ts:

export const PROMPT_VERSION = 1;

export function buildPrompt(rawText: string): string {
  return `You organize raw personal notes into structured metadata.

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.

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).
- Do NOT invent facts not present in the input.
- Do NOT include markdown code fences or preamble.
- Return ONLY the JSON object.`;
}
  • Step 5: Run + commit
npx vitest run tests/unit/ai-schema.test.ts
git add src/main/ai/schema.ts src/main/ai/prompt.ts tests/unit/ai-schema.test.ts
git commit -m "feat(ai): zod schema validator + Korean-first prompt"

Task 11: InferenceProvider interface

Files: Create src/main/ai/InferenceProvider.ts

  • Step 1: Implement
import type { AiResponse } from './schema.js';

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

export interface InferenceProvider {
  readonly name: string;
  generate(input: GenerateInput): Promise<AiResponse>;
  healthCheck(): Promise<HealthResult>;
}
  • Step 2: Commit
git add src/main/ai/InferenceProvider.ts
git commit -m "feat(ai): InferenceProvider interface"

Task 12: LocalOllamaProvider

Files: Create src/main/ai/LocalOllamaProvider.ts, tests/unit/LocalOllamaProvider.test.ts, tests/integration/ollama-golden.test.ts

  • Step 1: Failing unit tests
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';

describe('LocalOllamaProvider', () => {
  let mock: MockAgent;
  let original: ReturnType<typeof getGlobalDispatcher>;

  beforeEach(() => {
    original = getGlobalDispatcher();
    mock = new MockAgent();
    mock.disableNetConnect();
    setGlobalDispatcher(mock);
  });

  afterEach(async () => {
    setGlobalDispatcher(original);
    await mock.close();
  });

  it('generate parses Ollama JSON', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
      response: JSON.stringify({ title: '회의', summary: '첫\n둘\n셋', tags: ['api'] })
    });
    const r = await new LocalOllamaProvider().generate({ text: 'x' });
    expect(r.title).toBe('회의');
  });

  it('generate throws on non-JSON', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
      response: 'not json'
    });
    await expect(new LocalOllamaProvider().generate({ text: 'x' })).rejects.toThrow(/json/i);
  });

  it('generate aborts on timeout', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(() =>
      new Promise((resolve) => setTimeout(() => resolve({ statusCode: 200, data: '{}' }), 500))
    );
    await expect(
      new LocalOllamaProvider({ timeoutMs: 50 }).generate({ text: 'x' })
    ).rejects.toThrow();
  }, 2000);

  it('healthCheck ok=true when model present', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
      models: [{ name: 'gemma4:e4b' }]
    });
    const h = await new LocalOllamaProvider().healthCheck();
    expect(h.ok).toBe(true);
    expect(h.model).toBe('gemma4:e4b');
  });

  it('healthCheck ok=false when missing', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }).reply(200, {
      models: [{ name: 'other:latest' }]
    });
    const h = await new LocalOllamaProvider().healthCheck();
    expect(h.ok).toBe(false);
    expect(h.reason).toMatch(/gemma4:e4b/);
  });

  it('healthCheck ok=false on connection error', async () => {
    mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' })
      .replyWithError(new Error('ECONNREFUSED'));
    const h = await new LocalOllamaProvider().healthCheck();
    expect(h.ok).toBe(false);
    expect(h.reason).toMatch(/connect|refused|unreachable/i);
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/LocalOllamaProvider.test.ts → FAIL.

  • Step 3: Implement
import { request } from 'undici';
import { parseAiResponse, type AiResponse } from './schema.js';
import { buildPrompt } from './prompt.js';
import type { GenerateInput, HealthResult, InferenceProvider } from './InferenceProvider.js';

export interface LocalOllamaOptions {
  endpoint?: string;
  model?: string;
  timeoutMs?: number;
  temperature?: number;
  numPredict?: number;
}

export class LocalOllamaProvider implements InferenceProvider {
  readonly name: string;
  private endpoint: string;
  private model: string;
  private timeoutMs: number;
  private temperature: number;
  private numPredict: number;

  constructor(opts: LocalOllamaOptions = {}) {
    this.endpoint = opts.endpoint ?? 'http://localhost:11434';
    this.model = opts.model ?? 'gemma4:e4b';
    this.timeoutMs = opts.timeoutMs ?? 120_000;
    this.temperature = opts.temperature ?? 0.2;
    this.numPredict = opts.numPredict ?? 512;
    this.name = `local-ollama/${this.model}`;
  }

  async generate(input: GenerateInput): Promise<AiResponse> {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), this.timeoutMs);
    try {
      const res = await request(`${this.endpoint}/api/generate`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          model: this.model,
          prompt: buildPrompt(input.text),
          format: 'json',
          stream: false,
          options: { temperature: this.temperature, num_predict: this.numPredict }
        }),
        signal: controller.signal
      });
      if (res.statusCode < 200 || res.statusCode >= 300) {
        throw new Error(`ollama http ${res.statusCode}`);
      }
      const body = (await res.body.json()) as { response?: string };
      if (!body.response) throw new Error('missing response field');
      let parsed: unknown;
      try { parsed = JSON.parse(body.response); }
      catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
      return parseAiResponse(parsed);
    } finally {
      clearTimeout(timer);
    }
  }

  async healthCheck(): Promise<HealthResult> {
    try {
      const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' });
      if (res.statusCode !== 200) return { ok: false, reason: `tags http ${res.statusCode}` };
      const body = (await res.body.json()) as { models?: Array<{ name: string }> };
      const found = body.models?.some((m) => m.name === this.model);
      return found ? { ok: true, model: this.model }
                   : { ok: false, reason: `${this.model} not installed` };
    } catch (err) {
      return { ok: false, reason: `unreachable: ${(err as Error).message}` };
    }
  }
}
  • Step 4: Integration golden test (opt-in)

tests/integration/ollama-golden.test.ts:

import { describe, it, expect, beforeAll } from 'vitest';
import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js';

const skip = process.env.INKLING_INTEGRATION !== '1';

describe.skipIf(skip)('LocalOllamaProvider integration', () => {
  const provider = new LocalOllamaProvider({
    endpoint: process.env.INKLING_OLLAMA_ENDPOINT
  });

  beforeAll(async () => {
    const h = await provider.healthCheck();
    if (!h.ok) throw new Error(`Ollama not ready: ${h.reason}`);
  });

  const cases = [
    '회의 중 A프로젝트 API 타임아웃 문제가 재발했다는 보고를 받음.',
    'Stack trace: java.net.SocketTimeoutException at com.inkling.Api.call ... retried 3 times.',
    '오늘 점심 김치찌개 맛있었음. 오후에 디자인 미팅 있다.'
  ];

  it.each(cases)('Korean title + 3 lines for: %s', async (input) => {
    const r = await provider.generate({ text: input });
    expect(/[가-힣]/.test(r.title)).toBe(true);
    expect(r.summary.split('\n')).toHaveLength(3);
    for (const t of r.tags) expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
  }, 180_000);
});
  • Step 5: Run + commit
npx vitest run tests/unit/LocalOllamaProvider.test.ts
git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts tests/integration/ollama-golden.test.ts
git commit -m "feat(ai): LocalOllamaProvider with 120s timeout + integration harness"

Task 13: AiWorker

Files: Create src/main/ai/AiWorker.ts, tests/unit/AiWorker.test.ts

  • Step 1: Failing tests
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { AiWorker } from '@main/ai/AiWorker.js';
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';

function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
  return {
    name: 'mock',
    generate: vi.fn(async (): Promise<AiResponse> => ({
      title: '제목', summary: 'a\nb\nc', tags: ['tag']
    })),
    healthCheck: vi.fn(async () => ({ ok: true })),
    ...overrides
  } as InferenceProvider;
}

describe('AiWorker', () => {
  let db: Database.Database;
  let repo: NoteRepository;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
  });

  it('processes a pending job and marks done', async () => {
    const { id } = repo.create({ rawText: 'x' });
    const updates: string[] = [];
    const w = new AiWorker(repo, makeProvider(), {
      backoffsMs: [0, 0, 0],
      onUpdate: (note) => updates.push(note.aiStatus)
    });
    await w.enqueue(id);
    await w.drain();
    expect(repo.findById(id)?.aiStatus).toBe('done');
    expect(updates).toContain('done');
  });

  it('retries 3 times then marks failed', async () => {
    const { id } = repo.create({ rawText: 'x' });
    const provider = makeProvider({
      generate: vi.fn(async () => { throw new Error('boom'); })
    });
    const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
    await w.enqueue(id);
    await w.drain();
    const note = repo.findById(id)!;
    expect(note.aiStatus).toBe('failed');
    expect(note.aiError).toContain('boom');
    expect(provider.generate).toHaveBeenCalledTimes(3);
  });

  it('loadFromDb re-queues all pending', async () => {
    const a = repo.create({ rawText: 'a' }).id;
    const b = repo.create({ rawText: 'b' }).id;
    const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0] });
    await w.loadFromDb();
    await w.drain();
    expect(repo.findById(a)?.aiStatus).toBe('done');
    expect(repo.findById(b)?.aiStatus).toBe('done');
  });

  it('processes sequentially (concurrency 1)', async () => {
    const ids = [repo.create({ rawText: 'a' }).id, repo.create({ rawText: 'b' }).id];
    let running = 0;
    let max = 0;
    const provider = makeProvider({
      generate: vi.fn(async () => {
        running++; max = Math.max(max, running);
        await new Promise((r) => setTimeout(r, 10));
        running--;
        return { title: '제목', summary: 'a\nb\nc', tags: [] };
      })
    });
    const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
    for (const id of ids) await w.enqueue(id);
    await w.drain();
    expect(max).toBe(1);
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/AiWorker.test.ts → FAIL.

  • Step 3: Implement
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { InferenceProvider } from './InferenceProvider.js';
import type { Note } from '@shared/types';

export interface AiWorkerOptions {
  backoffsMs?: number[];
  onUpdate?: (note: Note) => void;
  logger?: {
    info: (msg: string, meta?: Record<string, unknown>) => void;
    warn: (msg: string, meta?: Record<string, unknown>) => void;
    error: (msg: string, meta?: Record<string, unknown>) => void;
  };
}

interface Job { noteId: string; attempts: number; }

export class AiWorker {
  private queue: Job[] = [];
  private running = false;
  private drainResolvers: Array<() => void> = [];
  private backoffsMs: number[];
  private onUpdate?: (note: Note) => void;
  private logger: NonNullable<AiWorkerOptions['logger']>;

  constructor(
    private repo: NoteRepository,
    private provider: InferenceProvider,
    opts: AiWorkerOptions = {}
  ) {
    this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
    this.onUpdate = opts.onUpdate;
    this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
  }

  async enqueue(noteId: string): Promise<void> {
    this.queue.push({ noteId, attempts: 0 });
    this.kick();
  }

  async loadFromDb(): Promise<void> {
    for (const j of this.repo.getAllPendingJobs()) {
      this.queue.push({ noteId: j.noteId, attempts: j.attempts });
    }
    this.kick();
  }

  async drain(): Promise<void> {
    if (!this.running && this.queue.length === 0) return;
    await new Promise<void>((resolve) => {
      this.drainResolvers.push(resolve);
      this.kick();
    });
  }

  private kick(): void {
    if (this.running) return;
    if (this.queue.length === 0) { this.resolveDrainers(); return; }
    this.running = true;
    void this.loop();
  }

  private async loop(): Promise<void> {
    try {
      while (this.queue.length > 0) {
        const job = this.queue.shift()!;
        await this.processJob(job);
      }
    } finally {
      this.running = false;
      this.resolveDrainers();
    }
  }

  private resolveDrainers(): void {
    const r = this.drainResolvers.splice(0);
    for (const fn of r) fn();
  }

  private async processJob(job: Job): Promise<void> {
    const max = this.backoffsMs.length;
    for (let attempt = job.attempts; attempt < max; attempt++) {
      try {
        const note = this.repo.findById(job.noteId);
        if (!note || note.aiStatus !== 'pending') return;
        const res = await this.provider.generate({ text: note.rawText });
        this.repo.updateAiResult(job.noteId, {
          title: res.title, summary: res.summary, tags: res.tags,
          provider: this.provider.name
        });
        this.logger.info('ai.done', { noteId: job.noteId, attempt });
        this.emit(job.noteId);
        return;
      } catch (err) {
        const isLast = attempt === max - 1;
        const msg = (err as Error).message;
        this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg });
        const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
        this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
        if (isLast) {
          this.repo.markAiFailed(job.noteId, msg);
          this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
          this.emit(job.noteId);
          return;
        }
        await this.sleep(this.backoffsMs[attempt + 1] ?? 0);
      }
    }
  }

  private emit(noteId: string): void {
    if (!this.onUpdate) return;
    const note = this.repo.findById(noteId);
    if (note) this.onUpdate(note);
  }

  private sleep(ms: number): Promise<void> {
    if (ms <= 0) return Promise.resolve();
    return new Promise((r) => setTimeout(r, ms));
  }
}
  • Step 4: Run + commit
npx vitest run tests/unit/AiWorker.test.ts
git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts
git commit -m "feat(ai): AiWorker with sequential queue, 3-attempt backoff"

Task 14: CaptureService (with notification trigger)

Files: Create src/main/services/CaptureService.ts, tests/unit/CaptureService.test.ts

  • Step 1: Failing tests
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { MediaStore } from '@main/services/MediaStore.js';
import { CaptureService } from '@main/services/CaptureService.js';

describe('CaptureService', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  let store: MediaStore;
  let tmp: string;
  let enqueued: string[];
  let celebrated: string[];
  let svc: CaptureService;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
    tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
    store = new MediaStore(tmp);
    enqueued = [];
    celebrated = [];
    svc = new CaptureService(repo, store, {
      enqueue: async (id) => { enqueued.push(id); },
      celebrate: (id) => { celebrated.push(id); }
    });
  });

  it('persists text-only and triggers enqueue + celebrate', async () => {
    const { noteId } = await svc.submit({ text: '안녕', images: [] });
    expect(repo.findById(noteId)?.rawText).toBe('안녕');
    expect(enqueued).toEqual([noteId]);
    expect(celebrated).toEqual([noteId]);
  });

  it('saves images under media/{noteId}/', async () => {
    const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer;
    const { noteId } = await svc.submit({ text: 'x', images: [img] });
    const note = repo.findById(noteId)!;
    expect(note.media).toHaveLength(1);
    expect(note.media[0].relPath.startsWith(`media/${noteId}/`)).toBe(true);
  });

  it('rejects empty submit', async () => {
    await expect(svc.submit({ text: '   ', images: [] })).rejects.toThrow(/empty/i);
    expect(celebrated).toHaveLength(0);
  });

  it('deleteNote removes db row + media dir', async () => {
    const img = new Uint8Array([0, 1, 2, 3]).buffer;
    const { noteId } = await svc.submit({ text: 't', images: [img] });
    await svc.deleteNote(noteId);
    expect(repo.findById(noteId)).toBeNull();
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/CaptureService.test.ts → FAIL.

  • Step 3: Implement
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { MediaStore } from './MediaStore.js';

export interface CaptureDeps {
  enqueue: (noteId: string) => Promise<void>;
  celebrate: (noteId: string) => void;
}

export interface SubmitInput {
  text: string;
  images: ArrayBuffer[];
}

export class CaptureService {
  constructor(
    private repo: NoteRepository,
    private store: MediaStore,
    private deps: CaptureDeps
  ) {}

  async submit(input: SubmitInput): Promise<{ noteId: string }> {
    const trimmed = input.text.trim();
    if (trimmed.length === 0 && input.images.length === 0) {
      throw new Error('empty submission');
    }
    const { id } = this.repo.create({ rawText: input.text });
    if (input.images.length > 0) {
      const rows = [];
      for (const img of input.images) {
        const buf = Buffer.from(img);
        const saved = await this.store.saveImage(id, buf, 'image/png');
        rows.push({
          noteId: id,
          kind: 'image' as const,
          relPath: saved.relPath,
          mime: saved.mime,
          bytes: saved.bytes
        });
      }
      this.repo.insertMedia(rows);
    }
    await this.deps.enqueue(id);
    this.deps.celebrate(id);
    return { noteId: id };
  }

  async deleteNote(noteId: string): Promise<void> {
    this.repo.delete(noteId);
    await this.store.deleteNoteDirectory(noteId);
  }
}
  • Step 4: Run + commit
npx vitest run tests/unit/CaptureService.test.ts
git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts
git commit -m "feat(capture): CaptureService with enqueue + celebrate hooks"

Task 15: NotificationService (post-submit reward toast)

Files: Create src/main/services/NotificationService.ts, tests/unit/NotificationService.test.ts

  • Step 1: Failing tests
import { describe, it, expect, vi } from 'vitest';
import { NotificationService, REWARD_COPIES } from '@main/services/NotificationService.js';

describe('NotificationService', () => {
  it('rotates copy deterministically by noteId hash', () => {
    const fired: string[] = [];
    const svc = new NotificationService({
      isSupported: () => true,
      send: (body) => { fired.push(body); }
    });
    const id1 = '00000000-0000-7000-8000-000000000001';
    svc.celebrate(id1);
    svc.celebrate(id1);
    expect(fired).toHaveLength(2);
    expect(fired[0]).toBe(fired[1]);
    expect(REWARD_COPIES).toContain(fired[0]);
  });

  it('different ids select different (eventually all 4)', () => {
    const fired: string[] = [];
    const svc = new NotificationService({
      isSupported: () => true,
      send: (body) => { fired.push(body); }
    });
    for (let i = 0; i < 32; i++) {
      svc.celebrate(`id-${i}`);
    }
    const distinct = new Set(fired);
    expect(distinct.size).toBe(REWARD_COPIES.length);
  });

  it('skips silently when notifications unsupported', () => {
    const fired: string[] = [];
    const svc = new NotificationService({
      isSupported: () => false,
      send: (body) => { fired.push(body); }
    });
    svc.celebrate('id-1');
    expect(fired).toHaveLength(0);
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/NotificationService.test.ts → FAIL.

  • Step 3: Implement
import { createHash } from 'node:crypto';

export const REWARD_COPIES = [
  '이 생각은 이제 Inkling이 들고 있습니다.',
  '나중에 찾을 수 있게 보관했습니다.',
  '방금 하나의 업무 기억을 구출했습니다.',
  '기록 완료. 이제 잊어도 됩니다.'
] as const;

export interface NotificationDeps {
  isSupported: () => boolean;
  send: (body: string) => void;
}

export class NotificationService {
  constructor(private deps: NotificationDeps) {}

  celebrate(noteId: string): void {
    if (!this.deps.isSupported()) return;
    const idx = this.pick(noteId);
    const body = REWARD_COPIES[idx]!;
    try {
      this.deps.send(body);
    } catch {
      // Swallow notification errors — capture must not fail because of toast.
    }
  }

  private pick(noteId: string): number {
    const hash = createHash('sha256').update(noteId).digest();
    return hash[0]! % REWARD_COPIES.length;
  }
}
  • Step 4: Run + commit
npx vitest run tests/unit/NotificationService.test.ts
git add src/main/services/NotificationService.ts tests/unit/NotificationService.test.ts
git commit -m "feat(notify): NotificationService with 4 rotating reward copies"

Task 16: IPC captureApi handlers

Files: Create src/main/ipc/captureApi.ts

  • Step 1: Implement
import { ipcMain } from 'electron';
import type { CaptureService } from '../services/CaptureService.js';
import type { BrowserWindow } from 'electron';

export function registerCaptureApi(
  captureService: CaptureService,
  getQuickCaptureWindow: () => BrowserWindow | null
): void {
  ipcMain.handle('capture:submit', async (_e, payload: { text: string; images: ArrayBuffer[] }) => {
    return captureService.submit(payload);
  });
  ipcMain.on('capture:hide', () => {
    getQuickCaptureWindow()?.hide();
  });
}
  • Step 2: Commit
git add src/main/ipc/captureApi.ts
git commit -m "feat(ipc): capture:submit and capture:hide handlers"

Task 17: HotkeyService

Files: Create src/main/services/HotkeyService.ts

  • Step 1: Implement
import { globalShortcut } from 'electron';

export interface HotkeyBinding {
  accelerator: string;
  onTrigger: () => void;
}

export class HotkeyService {
  private registered: string[] = [];

  register(binding: HotkeyBinding): { ok: boolean; reason?: string } {
    const accel = binding.accelerator;
    if (globalShortcut.isRegistered(accel)) {
      return { ok: false, reason: `${accel} already registered by another app` };
    }
    const ok = globalShortcut.register(accel, binding.onTrigger);
    if (!ok) return { ok: false, reason: `failed to register ${accel}` };
    this.registered.push(accel);
    return { ok: true };
  }

  unregisterAll(): void {
    for (const a of this.registered) globalShortcut.unregister(a);
    this.registered = [];
  }
}
  • Step 2: Commit
git add src/main/services/HotkeyService.ts
git commit -m "feat(hotkey): HotkeyService wrapping globalShortcut"

Task 18: QuickCaptureWindow

Files: Create src/main/windows/quickCaptureWindow.ts

  • Step 1: Implement
import { BrowserWindow, screen } from 'electron';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

let win: BrowserWindow | null = null;
const __dirname = fileURLToPath(new URL('.', import.meta.url));

export function getQuickCaptureWindow(): BrowserWindow | null { return win; }

export function createQuickCaptureWindow(): BrowserWindow {
  if (win && !win.isDestroyed()) return win;
  const primary = screen.getPrimaryDisplay();
  const W = 640, H = 280;
  const x = Math.round((primary.workArea.width - W) / 2 + primary.workArea.x);
  const y = Math.round((primary.workArea.height - H) / 3 + primary.workArea.y);

  win = new BrowserWindow({
    width: W, height: H, x, y,
    frame: false, show: false, alwaysOnTop: true,
    skipTaskbar: true, resizable: false,
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      contextIsolation: true, nodeIntegration: false, sandbox: false
    }
  });

  if (process.env.ELECTRON_RENDERER_URL) {
    win.loadURL(`${process.env.ELECTRON_RENDERER_URL}/quickcapture/index.html`);
  } else {
    win.loadFile(join(__dirname, '../renderer/quickcapture/index.html'));
  }

  win.on('blur', () => { if (win?.isVisible()) win.hide(); });
  return win;
}

export function showQuickCapture(): void {
  const w = createQuickCaptureWindow();
  w.show(); w.focus();
}
  • Step 2: Commit
git add src/main/windows/quickCaptureWindow.ts
git commit -m "feat(window): frameless QuickCaptureWindow centered on primary display"

Task 19: QuickCapture renderer (with v0.2 copy)

Files: Create src/renderer/quickcapture/index.html, main.tsx, App.tsx, api.ts

  • Step 1: HTML
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
    <title>Inkling Capture</title>
    <style>
      html, body, #root { margin: 0; height: 100%; background: transparent; font-family: system-ui, sans-serif; }
      body { -webkit-app-region: drag; }
      .card {
        -webkit-app-region: no-drag;
        background: #1e1e24; color: #eee;
        border-radius: 12px; box-shadow: 0 12px 48px rgba(0,0,0,0.4);
        height: calc(100% - 24px); margin: 12px;
        display: flex; flex-direction: column; padding: 12px;
      }
      textarea { flex: 1; background: transparent; color: inherit; border: none; outline: none; font-size: 14px; resize: none; }
      .thumbs { display: flex; gap: 6px; }
      .thumbs img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; }
      .hint { color: #888; font-size: 11px; margin-top: 6px; }
      .err { color: #ff6a6a; font-size: 11px; margin-top: 6px; }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/renderer/quickcapture/main.tsx"></script>
  </body>
</html>
  • Step 2: api.ts
import type { CaptureApi } from '@shared/types';
export const captureApi: CaptureApi = window.inkling.capture;
  • Step 3: main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
createRoot(document.getElementById('root')!).render(<App />);
  • Step 4: App.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { captureApi } from './api.js';

interface PastedImage { url: string; buffer: ArrayBuffer; }

export function App(): React.ReactElement {
  const [text, setText] = useState('');
  const [images, setImages] = useState<PastedImage[]>([]);
  const [err, setErr] = useState<string | null>(null);
  const ref = useRef<HTMLTextAreaElement>(null);

  useEffect(() => { ref.current?.focus(); }, []);

  const submit = useCallback(async () => {
    setErr(null);
    if (text.trim().length === 0 && images.length === 0) return;
    try {
      await captureApi.submit({ text, images: images.map((i) => i.buffer) });
      setText(''); setImages([]); captureApi.hide();
    } catch (e) { setErr('저장에 실패했습니다. 다시 시도해주세요.'); }
  }, [text, images]);

  const cancel = useCallback(() => {
    if (text.trim().length > 5) {
      const ok = window.confirm('이 한 줄을 흘려보낼까요?');
      if (!ok) return;
    }
    setText(''); setImages([]); captureApi.hide();
  }, [text]);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') { e.preventDefault(); cancel(); }
      else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void submit(); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [cancel, submit]);

  const onPaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
    const items = Array.from(e.clipboardData.items);
    const imgs = items.filter((i) => i.type.startsWith('image/'));
    if (imgs.length === 0) return;
    e.preventDefault();
    for (const it of imgs) {
      const blob = it.getAsFile();
      if (!blob) continue;
      const buffer = await blob.arrayBuffer();
      const url = URL.createObjectURL(blob);
      setImages((prev) => [...prev, { url, buffer }]);
    }
  }, []);

  return (
    <div className="card">
      <textarea
        ref={ref}
        value={text}
        onChange={(e) => setText(e.target.value)}
        onPaste={onPaste}
        placeholder="지금 머릿속에 있는 것 한 줄. 정리는 나중입니다."
      />
      {images.length > 0 && (
        <div className="thumbs">
          {images.map((i, idx) => (<img key={idx} src={i.url} alt="" />))}
        </div>
      )}
      <div className="hint">Ctrl+Enter 구출 · Esc 취소 · 이미지 붙여넣기</div>
      {err && <div className="err">{err}</div>}
    </div>
  );
}
  • Step 5: Commit
git add src/renderer/quickcapture/
git commit -m "feat(quickcapture): React UI with v0.2 recovery-friendly copy"

Task 20: IntentService

Files: Create src/main/services/IntentService.ts, tests/unit/IntentService.test.ts

  • Step 1: Failing tests
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { IntentService } from '@main/services/IntentService.js';

describe('IntentService', () => {
  let db: Database.Database;
  let repo: NoteRepository;
  let svc: IntentService;

  beforeEach(() => {
    db = new Database(':memory:');
    runMigrations(db);
    repo = new NoteRepository(db);
    svc = new IntentService(repo);
  });

  it('setIntent stores text on note', () => {
    const { id } = repo.create({ rawText: 'x' });
    svc.setIntent(id, '내일의 나에게');
    expect(repo.findById(id)?.userIntent).toBe('내일의 나에게');
  });

  it('dismissIntent stamps prompted_at without setting intent', () => {
    const { id } = repo.create({ rawText: 'x' });
    svc.dismissIntent(id);
    const note = repo.findById(id)!;
    expect(note.userIntent).toBeNull();
    expect(note.intentPromptedAt).not.toBeNull();
  });

  it('rejects empty intent text', () => {
    const { id } = repo.create({ rawText: 'x' });
    expect(() => svc.setIntent(id, '   ')).toThrow(/empty/i);
  });

  it('throws if note does not exist', () => {
    expect(() => svc.setIntent('nonexistent', 'x')).toThrow(/not found/i);
    expect(() => svc.dismissIntent('nonexistent')).toThrow(/not found/i);
  });
});
  • Step 2: Verify failure

npx vitest run tests/unit/IntentService.test.ts → FAIL.

  • Step 3: Implement
import type { NoteRepository } from '../repository/NoteRepository.js';

export class IntentService {
  constructor(private repo: NoteRepository) {}

  setIntent(noteId: string, text: string): void {
    if (text.trim().length === 0) throw new Error('empty intent text');
    if (!this.repo.findById(noteId)) throw new Error('note not found');
    this.repo.setIntent(noteId, text);
  }

  dismissIntent(noteId: string): void {
    if (!this.repo.findById(noteId)) throw new Error('note not found');
    this.repo.dismissIntent(noteId);
  }
}
  • Step 4: Run + commit
npx vitest run tests/unit/IntentService.test.ts
git add src/main/services/IntentService.ts tests/unit/IntentService.test.ts
git commit -m "feat(intent): IntentService for set/dismiss with validation"

Task 21: IPC inboxApi (with v0.2 endpoints)

Files: Create src/main/ipc/inboxApi.ts

  • Step 1: Implement
import { ipcMain, BrowserWindow } from 'electron';
import type { NoteRepository } from '../repository/NoteRepository.js';
import type { ContinuityService } from '../services/ContinuityService.js';
import type { CaptureService } from '../services/CaptureService.js';
import type { HealthChecker } from '../services/HealthChecker.js';
import type { IntentService } from '../services/IntentService.js';
import type { Note } from '@shared/types';

export interface InboxIpcDeps {
  repo: NoteRepository;
  continuity: ContinuityService;
  capture: CaptureService;
  health: HealthChecker;
  intent: IntentService;
  getInboxWindow: () => BrowserWindow | null;
}

export function registerInboxApi(deps: InboxIpcDeps): void {
  ipcMain.handle('inbox:list', (_e, opts: { limit: number; cursor?: string }) =>
    deps.repo.list(opts)
  );

  ipcMain.handle(
    'inbox:updateAi',
    (_e, arg: { noteId: string; fields: { title?: string; summary?: string; tags?: string[] } }) => {
      deps.repo.updateUserAiFields(arg.noteId, arg.fields);
    }
  );

  ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
    await deps.capture.deleteNote(noteId);
  });

  ipcMain.handle(
    'inbox:setIntent',
    (_e, arg: { noteId: string; text: string }) => {
      deps.intent.setIntent(arg.noteId, arg.text);
    }
  );

  ipcMain.handle('inbox:dismissIntent', (_e, noteId: string) => {
    deps.intent.dismissIntent(noteId);
  });

  ipcMain.handle('inbox:continuity', () => deps.continuity.get());
  ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount());
  ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus());
}

export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
  const w = getWin();
  if (!w || w.isDestroyed()) return;
  w.webContents.send('note:updated', note);
}
  • Step 2: Commit
git add src/main/ipc/inboxApi.ts
git commit -m "feat(ipc): inbox handlers with v0.2 setIntent/dismissIntent/continuity"

Task 22: Inbox React shell + store + recovery toast helper

Files: Replace src/renderer/inbox/index.html. Create src/renderer/inbox/main.tsx, App.tsx, store.ts, api.ts, recoveryToast.ts. Create component stubs for NoteCard, EditableField, IntentBanner, RecoveryToast, ContinuityBadge, PendingBanner, OllamaBanner so the shell compiles before later tasks fill them.

  • Step 1: Replace index.html body
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file:" />
    <title>Inkling</title>
    <style>
      body { margin: 0; font-family: system-ui, sans-serif; background: #f5f5f7; color: #111; }
      .header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: white; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 10; }
      .main { max-width: 780px; margin: 0 auto; padding: 20px; }
      .banner { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 13px; display: flex; justify-content: space-between; align-items: center; }
      .banner.warn { background: #fff4d6; color: #7a5a00; }
      .banner.info { background: #e3f2ff; color: #0a4b80; }
      .banner.recovery { background: #e9f9e4; color: #236b1a; }
      .banner button { background: none; border: none; color: inherit; cursor: pointer; font-size: 13px; }
      .empty { text-align: center; color: #888; padding: 60px 0; }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/renderer/inbox/main.tsx"></script>
  </body>
</html>
  • Step 2: api.ts
import type { InboxApi } from '@shared/types';
export const inboxApi: InboxApi = window.inkling.inbox;
  • Step 3: recoveryToast.ts
const KEY = 'inkling.recoveryDismissedAt';

export function isRecoveryDismissedToday(now = new Date()): boolean {
  const v = localStorage.getItem(KEY);
  if (!v) return false;
  const stored = new Date(v);
  // Same KST date?
  const kstNow = new Date(now.getTime() + 9 * 3600_000).toISOString().slice(0, 10);
  const kstStored = new Date(stored.getTime() + 9 * 3600_000).toISOString().slice(0, 10);
  return kstNow === kstStored;
}

export function markRecoveryDismissed(now = new Date()): void {
  localStorage.setItem(KEY, now.toISOString());
}
  • Step 4: Component stubs

src/renderer/inbox/components/NoteCard.tsx:

import React from 'react';
import type { Note } from '@shared/types';
export function NoteCard({ note }: { note: Note; onDeleted: () => void; onUpdated: (n: Note) => void }) {
  return <div style={{ background: 'white', padding: 12, marginBottom: 10, borderRadius: 8 }}>{note.rawText}</div>;
}

src/renderer/inbox/components/EditableField.tsx:

import React, { CSSProperties } from 'react';
export function EditableField(props: {
  value: string;
  onSave: (next: string) => Promise<void>;
  style?: CSSProperties;
  singleLine?: boolean;
}): React.ReactElement {
  return <div style={props.style}>{props.value}</div>;
}

src/renderer/inbox/components/IntentBanner.tsx:

import React from 'react';
export function IntentBanner(_: { noteId: string; onResolved: () => void }) { return null; }

src/renderer/inbox/components/RecoveryToast.tsx:

import React from 'react';
export function RecoveryToast(_: { show: boolean; onDismiss: () => void }) { return null; }

src/renderer/inbox/components/ContinuityBadge.tsx:

import React from 'react';
export function ContinuityBadge() { return null; }

src/renderer/inbox/components/PendingBanner.tsx:

import React from 'react';
export function PendingBanner() { return null; }

src/renderer/inbox/components/OllamaBanner.tsx:

import React from 'react';
export function OllamaBanner() { return null; }
  • Step 5: Zustand store

src/renderer/inbox/store.ts:

import { create } from 'zustand';
import type { Note, WeeklyContinuity } from '@shared/types';
import { inboxApi } from './api.js';

interface InboxState {
  notes: Note[];
  continuity: WeeklyContinuity;
  pendingCount: number;
  ollamaStatus: { ok: boolean; reason?: string };
  loading: boolean;
  loadInitial: () => Promise<void>;
  refreshMeta: () => Promise<void>;
  upsertNote: (note: Note) => void;
  removeNote: (id: string) => void;
}

const emptyContinuity: WeeklyContinuity = {
  weekStart: '', weekCount: 0, weekTarget: 7,
  consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
};

export const useInbox = create<InboxState>((set, get) => ({
  notes: [],
  continuity: emptyContinuity,
  pendingCount: 0,
  ollamaStatus: { ok: true },
  loading: false,
  async loadInitial() {
    set({ loading: true });
    const [notes, continuity, pendingCount, ollamaStatus] = await Promise.all([
      inboxApi.listNotes({ limit: 50 }),
      inboxApi.getContinuity(),
      inboxApi.getPendingCount(),
      inboxApi.getOllamaStatus()
    ]);
    set({ notes, continuity, pendingCount, ollamaStatus, loading: false });
  },
  async refreshMeta() {
    const [continuity, pendingCount, ollamaStatus] = await Promise.all([
      inboxApi.getContinuity(),
      inboxApi.getPendingCount(),
      inboxApi.getOllamaStatus()
    ]);
    set({ continuity, pendingCount, ollamaStatus });
  },
  upsertNote(note) {
    const i = get().notes.findIndex((n) => n.id === note.id);
    if (i >= 0) {
      const next = get().notes.slice();
      next[i] = note;
      set({ notes: next });
    } else {
      set({ notes: [note, ...get().notes] });
    }
  },
  removeNote(id) {
    set({ notes: get().notes.filter((n) => n.id !== id) });
  }
}));
  • Step 6: main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
createRoot(document.getElementById('root')!).render(<App />);
  • Step 7: App.tsx
import React, { useEffect, useState } from 'react';
import { useInbox } from './store.js';
import { inboxApi } from './api.js';
import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js';
import { NoteCard } from './components/NoteCard.js';
import { ContinuityBadge } from './components/ContinuityBadge.js';
import { PendingBanner } from './components/PendingBanner.js';
import { OllamaBanner } from './components/OllamaBanner.js';
import { RecoveryToast } from './components/RecoveryToast.js';

export function App(): React.ReactElement {
  const { notes, loading, loadInitial, refreshMeta, upsertNote, removeNote, continuity } = useInbox();
  const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());

  useEffect(() => {
    void loadInitial();
    const unsub = inboxApi.onNoteUpdated((note) => {
      upsertNote(note);
      void refreshMeta();
    });
    const onFocus = () => { void refreshMeta(); };
    window.addEventListener('focus', onFocus);
    return () => { unsub(); window.removeEventListener('focus', onFocus); };
  }, [loadInitial, refreshMeta, upsertNote]);

  const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;

  return (
    <>
      <div className="header">
        <h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
        <ContinuityBadge />
      </div>
      <main className="main">
        <OllamaBanner />
        <RecoveryToast
          show={showRecovery}
          onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
        />
        <PendingBanner />
        {loading && notes.length === 0 ? (
          <div className="empty">불러오는 중…</div>
        ) : notes.length === 0 ? (
          <div className="empty"> 기억을 구출해보세요. <code>Ctrl+Shift+J</code></div>
        ) : (
          notes.map((n) => (
            <NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
          ))
        )}
      </main>
    </>
  );
}
  • Step 8: Commit
git add src/renderer/inbox/
git commit -m "feat(inbox): React shell + store + component stubs (v0.2)"

Task 23: ContinuityBadge

Files: Replace src/renderer/inbox/components/ContinuityBadge.tsx

  • Step 1: Implement
import React from 'react';
import { useInbox } from '../store.js';

export function ContinuityBadge(): React.ReactElement | null {
  const c = useInbox((s) => s.continuity);
  if (!c.weekStart) return null;
  if (c.weekCount === 0) {
    return <div style={{ fontSize: 13, color: '#666' }}>이번   줄이면 시작입니다</div>;
  }
  if (c.weekCount < c.weekTarget) {
    return (
      <div style={{ fontSize: 13, color: '#444' }}>
        이번  <b>{c.weekCount}/{c.weekTarget}</b>
      </div>
    );
  }
  return (
    <div style={{ fontSize: 13, color: '#236b1a' }}>
      이번  <b>{c.weekCount}/{c.weekTarget}</b> 
      {c.consecutiveCompleteWeeks > 0 && (
        <span style={{ color: '#888' }}> · 연속 {c.consecutiveCompleteWeeks} 완성</span>
      )}
    </div>
  );
}
  • Step 2: Commit
git add src/renderer/inbox/components/ContinuityBadge.tsx
git commit -m "feat(inbox): ContinuityBadge with recovery-friendly copy"

Task 24: PendingBanner

Files: Replace src/renderer/inbox/components/PendingBanner.tsx

  • Step 1: Implement
import React from 'react';
import { useInbox } from '../store.js';

export function PendingBanner(): React.ReactElement | null {
  const count = useInbox((s) => s.pendingCount);
  if (count === 0) return null;
  return (
    <div className="banner info">
      <span>🟡 Inkling이 정리하는 : <b>{count}</b></span>
    </div>
  );
}
  • Step 2: Commit
git add src/renderer/inbox/components/PendingBanner.tsx
git commit -m "feat(inbox): PendingBanner with v0.2 copy"

Task 25: NoteCard with AI labels + intent badge + IntentBanner integration

Files: Replace src/renderer/inbox/components/NoteCard.tsx

  • Step 1: Implement
import React, { useState } from 'react';
import type { Note } from '@shared/types';
import { inboxApi } from '../api.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';

interface Props {
  note: Note;
  onDeleted: () => void;
  onUpdated: (n: Note) => void;
}

const aiBadgeStyle: React.CSSProperties = {
  display: 'inline-block', marginLeft: 6, padding: '1px 5px',
  background: '#eee', color: '#666', fontSize: 10, borderRadius: 3, verticalAlign: 'middle'
};

export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
  const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
  const [local, setLocal] = useState(note);

  React.useEffect(() => { setLocal(note); }, [note]);

  const formatted = new Date(note.createdAt).toLocaleString('ko-KR');

  async function handleDelete() {
    if (!window.confirm('이 기억을 버릴까요? 되돌릴 수 없습니다.')) return;
    await inboxApi.deleteNote(note.id);
    onDeleted();
  }

  async function saveTitle(next: string) {
    await inboxApi.updateAiFields(note.id, { title: next });
    const updated = { ...local, aiTitle: next, titleEditedByUser: true };
    setLocal(updated); onUpdated(updated);
  }

  async function saveSummary(next: string) {
    await inboxApi.updateAiFields(note.id, { summary: next });
    const updated = { ...local, aiSummary: next, summaryEditedByUser: true };
    setLocal(updated); onUpdated(updated);
  }

  async function removeTag(tagName: string) {
    const next = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
    await inboxApi.updateAiFields(note.id, { tags: next });
    const updated = { ...local, tags: local.tags.filter((t) => t.name !== tagName) };
    setLocal(updated); onUpdated(updated);
  }

  async function saveIntent(next: string) {
    await inboxApi.setIntent(note.id, next);
    const now = new Date().toISOString();
    const updated = { ...local, userIntent: next, intentPromptedAt: local.intentPromptedAt ?? now };
    setLocal(updated); onUpdated(updated);
  }

  const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;

  return (
    <div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
      <div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>

      {showIntentBanner && (
        <IntentBanner
          noteId={note.id}
          onResolved={(intentText) => {
            const now = new Date().toISOString();
            const updated = { ...local, userIntent: intentText ?? null, intentPromptedAt: now };
            setLocal(updated); onUpdated(updated);
          }}
        />
      )}

      {local.aiStatus === 'pending' && (
        <div style={{ fontSize: 16, fontWeight: 600, color: '#666', marginTop: 4 }}>
          Inkling이 정리하는 중…
        </div>
      )}
      {local.aiStatus === 'failed' && (
        <div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55', marginTop: 4 }}>
          정리 보류  원문은 안전합니다
        </div>
      )}
      {local.aiStatus === 'done' && (
        <>
          <div style={{ marginTop: 4 }}>
            <EditableField
              value={local.aiTitle ?? ''}
              onSave={saveTitle}
              style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
              singleLine
            />
            {!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
          </div>
          <div style={{ marginTop: 6 }}>
            <EditableField
              value={local.aiSummary ?? ''}
              onSave={saveSummary}
              style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
              singleLine={false}
            />
            {!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
          </div>
          {local.tags.length > 0 && (
            <div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              {local.tags.map((t) => (
                <span
                  key={t.name}
                  onClick={() => void removeTag(t.name)}
                  style={{
                    background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
                    color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
                    padding: '2px 8px', borderRadius: 12, fontSize: 12, cursor: 'pointer'
                  }}
                  title={t.source === 'ai' ? 'AI 제안 — 클릭으로 제거' : '내가 추가 — 클릭으로 제거'}
                >
                  {t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
                </span>
              ))}
            </div>
          )}
          {local.userIntent !== null && (
            <div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
              <span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
              <EditableField
                value={local.userIntent}
                onSave={saveIntent}
                style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
                singleLine
              />
            </div>
          )}
        </>
      )}

      {local.media.length > 0 && (
        <div style={{ marginTop: 10, display: 'flex', gap: 6 }}>
          {local.media.map((m) => (
            <div key={m.id} style={{ width: 48, height: 48, background: '#eee', borderRadius: 4 }} title={m.relPath} />
          ))}
        </div>
      )}

      <div style={{ marginTop: 10 }}>
        <button onClick={() => setRawOpen((o) => !o)} style={{ background: 'none', border: 'none', color: '#555', fontSize: 12, cursor: 'pointer', padding: 0 }}>
          {rawOpen ? '▾ 원문 접기' : '▸ 원문 보기'}
        </button>
        {rawOpen && (
          <pre style={{ marginTop: 6, whiteSpace: 'pre-wrap', fontSize: 12, color: '#555', background: '#fafafa', padding: 8, borderRadius: 4 }}>
            {local.rawText}
          </pre>
        )}
      </div>

      <div style={{ marginTop: 10, textAlign: 'right' }}>
        <button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
          🗑 삭제
        </button>
      </div>
    </div>
  );
}
  • Step 2: Commit
git add src/renderer/inbox/components/NoteCard.tsx
git commit -m "feat(inbox): NoteCard with AI proposal labels, intent badge, IntentBanner slot"

Task 26: EditableField

Files: Replace src/renderer/inbox/components/EditableField.tsx (stub from Task 22)

  • Step 1: Implement
import React, { useEffect, useRef, useState, CSSProperties } from 'react';

interface Props {
  value: string;
  onSave: (next: string) => Promise<void>;
  style?: CSSProperties;
  singleLine?: boolean;
}

export function EditableField({
  value, onSave, style, singleLine = true
}: Props): React.ReactElement {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState(value);
  const [error, setError] = useState(false);
  const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);

  useEffect(() => { if (!editing) setDraft(value); }, [value, editing]);
  useEffect(() => { if (editing) ref.current?.focus(); }, [editing]);

  async function commit() {
    if (draft === value) { setEditing(false); return; }
    try { await onSave(draft); setEditing(false); }
    catch { setError(true); setDraft(value); setTimeout(() => setError(false), 800); }
  }

  if (!editing) {
    return (
      <span
        onClick={() => setEditing(true)}
        style={{ ...style, cursor: 'text', outline: error ? '1px solid #c93030' : 'none' }}
      >
        {value || <em style={{ color: '#bbb' }}>(비어 있음)</em>}
      </span>
    );
  }

  if (singleLine) {
    return (
      <input
        ref={ref as React.RefObject<HTMLInputElement>}
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onBlur={commit}
        onKeyDown={(e) => {
          if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
          if (e.key === 'Escape') { setDraft(value); setEditing(false); }
        }}
        style={{ ...style, border: '1px solid #ccc', borderRadius: 4, padding: 2 }}
      />
    );
  }

  return (
    <textarea
      ref={ref as React.RefObject<HTMLTextAreaElement>}
      value={draft}
      onChange={(e) => setDraft(e.target.value)}
      onBlur={commit}
      onKeyDown={(e) => {
        if (e.key === 'Escape') { setDraft(value); setEditing(false); }
      }}
      style={{ ...style, border: '1px solid #ccc', borderRadius: 4, padding: 4, width: '100%', minHeight: 60 }}
    />
  );
}
  • Step 2: Commit
git add src/renderer/inbox/components/EditableField.tsx
git commit -m "feat(inbox): EditableField with blur-save, enter-commit, esc-cancel"

Task 27: IntentBanner component

Files: Replace src/renderer/inbox/components/IntentBanner.tsx. Create src/shared/intentPrompts.ts.

  • Step 1: Prompt constants

src/shared/intentPrompts.ts:

export const INTENT_PROMPTS = [
  '내일의 내가 이 메모에서 꼭 알아야 할 것은?',
  '이 메모가 중요한 이유를 한 줄로?',
  '이 문제를 다시 만나면 무엇을 먼저 확인할까요?',
  '동료에게 공유한다면 제목을 뭐라고?'
] as const;

export function pickIntentPrompt(noteId: string): string {
  let h = 0;
  for (let i = 0; i < noteId.length; i++) h = (h * 31 + noteId.charCodeAt(i)) | 0;
  const idx = Math.abs(h) % INTENT_PROMPTS.length;
  return INTENT_PROMPTS[idx]!;
}
  • Step 2: Implement IntentBanner
import React, { useState, useCallback } from 'react';
import { inboxApi } from '../api.js';
import { pickIntentPrompt } from '@shared/intentPrompts';

interface Props {
  noteId: string;
  onResolved: (intentText: string | null) => void;
}

export function IntentBanner({ noteId, onResolved }: Props): React.ReactElement {
  const [draft, setDraft] = useState('');
  const [busy, setBusy] = useState(false);
  const prompt = pickIntentPrompt(noteId);

  const submit = useCallback(async () => {
    const text = draft.trim();
    if (text.length === 0) return;
    setBusy(true);
    try {
      await inboxApi.setIntent(noteId, text);
      onResolved(text);
    } finally { setBusy(false); }
  }, [draft, noteId, onResolved]);

  const skip = useCallback(async () => {
    setBusy(true);
    try {
      await inboxApi.dismissIntent(noteId);
      onResolved(null);
    } finally { setBusy(false); }
  }, [noteId, onResolved]);

  return (
    <div style={{ marginTop: 8, padding: 10, background: '#fff8d6', borderRadius: 8, border: '1px solid #f1d97c' }}>
      <div style={{ fontSize: 12, color: '#7a5a00', marginBottom: 6 }}>💭 {prompt}</div>
      <div style={{ display: 'flex', gap: 6 }}>
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }}
          placeholder="한 줄 입력 (200자)"
          disabled={busy}
          style={{ flex: 1, border: '1px solid #ddd', borderRadius: 4, padding: '4px 6px', fontSize: 13 }}
          maxLength={200}
        />
        <button onClick={() => void submit()} disabled={busy || draft.trim().length === 0}
          style={{ background: '#f1d97c', border: 'none', borderRadius: 4, padding: '4px 10px', cursor: 'pointer', fontSize: 12 }}>
          저장
        </button>
        <button onClick={() => void skip()} disabled={busy}
          style={{ background: 'transparent', border: 'none', color: '#7a5a00', cursor: 'pointer', fontSize: 12 }}>
          건너뛰기
        </button>
      </div>
    </div>
  );
}
  • Step 3: Commit
git add src/renderer/inbox/components/IntentBanner.tsx src/shared/intentPrompts.ts
git commit -m "feat(inbox): IntentBanner with rotating prompts (Strategy §2.2)"

Task 28: RecoveryToast component

Files: Replace src/renderer/inbox/components/RecoveryToast.tsx

  • Step 1: Implement
import React from 'react';

interface Props {
  show: boolean;
  onDismiss: () => void;
}

export function RecoveryToast({ show, onDismiss }: Props): React.ReactElement | null {
  if (!show) return null;
  return (
    <div className="banner recovery">
      <span>🌱 흐름을 다시 이어갑니다</span>
      <button onClick={onDismiss} aria-label="닫기"></button>
    </div>
  );
}
  • Step 2: Commit
git add src/renderer/inbox/components/RecoveryToast.tsx
git commit -m "feat(inbox): RecoveryToast for ≥7-day gap reentry"

Task 29: HealthChecker + OllamaBanner with v0.2 copy

Files: Create src/main/services/HealthChecker.ts. Replace src/renderer/inbox/components/OllamaBanner.tsx.

  • Step 1: HealthChecker
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';

export class HealthChecker {
  private last: HealthResult = { ok: true };
  constructor(private provider: InferenceProvider) {}

  async runOnce(): Promise<HealthResult> {
    this.last = await this.provider.healthCheck();
    return this.last;
  }

  lastStatus(): HealthResult { return this.last; }
}
  • Step 2: OllamaBanner
import React from 'react';
import { useInbox } from '../store.js';

export function OllamaBanner(): React.ReactElement | null {
  const status = useInbox((s) => s.ollamaStatus);
  if (status.ok) return null;
  const isMissing = status.reason?.includes('not installed');
  const message = isMissing
    ? '`ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요.'
    : 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
  return (
    <div className="banner warn">
      <span> {message}</span>
    </div>
  );
}
  • Step 3: Commit
git add src/main/services/HealthChecker.ts src/renderer/inbox/components/OllamaBanner.tsx
git commit -m "feat(health): HealthChecker + OllamaBanner v0.2 copy"

Task 30: Tray + main wiring with NotificationService + IntentService

Files: Create src/main/tray.ts. Replace src/main/index.ts.

  • Step 1: Tray with v0.2 copy
import { app, Tray, Menu, nativeImage } from 'electron';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

let tray: Tray | null = null;
const __dirname = fileURLToPath(new URL('.', import.meta.url));

export function createTray(showInbox: () => void, showCapture: () => void): Tray {
  const icon = nativeImage.createEmpty();
  tray = new Tray(icon);
  tray.setToolTip('Inkling');
  const menu = Menu.buildFromTemplate([
    { label: '구출한 메모 보기', click: showInbox },
    { label: '기억 구출하기', click: showCapture },
    { type: 'separator' },
    { label: '종료', click: () => { app.isQuitting = true; app.quit(); } }
  ]);
  tray.setContextMenu(menu);
  tray.on('click', showInbox);
  return tray;
}
  • Step 2: src/main/index.ts final wiring
import { app, BrowserWindow, Notification } from 'electron';
import '@shared/types';
import { initLogger, logger } from './logger.js';
import { resolveProfilePaths } from './paths.js';
import { openDb } from './db/index.js';
import { NoteRepository } from './repository/NoteRepository.js';
import { MediaStore } from './services/MediaStore.js';
import { ContinuityService } from './services/ContinuityService.js';
import { CaptureService } from './services/CaptureService.js';
import { NotificationService } from './services/NotificationService.js';
import { HotkeyService } from './services/HotkeyService.js';
import { IntentService } from './services/IntentService.js';
import { HealthChecker } from './services/HealthChecker.js';
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
import { AiWorker } from './ai/AiWorker.js';
import { registerCaptureApi } from './ipc/captureApi.js';
import { registerInboxApi, pushNoteUpdated } from './ipc/inboxApi.js';
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
import {
  createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
} from './windows/quickCaptureWindow.js';
import { createTray } from './tray.js';
import { MediaGc } from './services/MediaGc.js';

app.whenReady().then(async () => {
  initLogger();
  logger.info('app.start', { platform: process.platform, version: app.getVersion() });

  const paths = resolveProfilePaths('default');
  const db = openDb(paths.dbFile);
  const repo = new NoteRepository(db);
  const store = new MediaStore(paths.profileDir);
  const continuity = new ContinuityService(db);
  const intent = new IntentService(repo);

  const provider = new LocalOllamaProvider({
    endpoint: process.env.INKLING_OLLAMA_ENDPOINT
  });
  const health = new HealthChecker(provider);
  void health.runOnce().then((h) => logger.info('ai.health', h));

  const worker = new AiWorker(repo, provider, {
    onUpdate: (note) => pushNoteUpdated(getInboxWindow, note),
    logger
  });

  const notify = new NotificationService({
    isSupported: () => Notification.isSupported(),
    send: (body) => {
      new Notification({ title: 'Inkling', body, silent: false }).show();
    }
  });

  const capture = new CaptureService(repo, store, {
    enqueue: (id) => worker.enqueue(id),
    celebrate: (id) => notify.celebrate(id)
  });

  registerCaptureApi(capture, getQuickCaptureWindow);
  registerInboxApi({
    repo, continuity, capture, health, intent,
    getInboxWindow
  });

  const hotkeys = new HotkeyService();
  const reg = hotkeys.register({
    accelerator: process.platform === 'darwin' ? 'Cmd+Shift+J' : 'Ctrl+Shift+J',
    onTrigger: () => showQuickCapture()
  });
  if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });

  createInboxWindow();
  createQuickCaptureWindow();
  createTray(
    () => createInboxWindow(),
    () => showQuickCapture()
  );

  await worker.loadFromDb();

  const gc = new MediaGc(db, store);
  void gc.run().then((r) => logger.info('media.gc', r));

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
  });
});

app.on('before-quit', () => { app.isQuitting = true; });
  • Step 3: Verify dev

npm run dev — submit a note, expect OS notification fires (or silent fallback if denied), Inbox shows pending → done, IntentBanner appears once on done card.

  • Step 4: Commit
git add src/main/tray.ts src/main/index.ts
git commit -m "feat(app): wire NotificationService, IntentService, ContinuityService into main"

Task 31: MediaGc

Files: Create src/main/services/MediaGc.ts

  • Step 1: Implement
import type Database from 'better-sqlite3';
import type { MediaStore } from './MediaStore.js';

export class MediaGc {
  constructor(private db: Database.Database, private store: MediaStore) {}

  async run(): Promise<{ removed: number }> {
    const dirs = await this.store.listNoteDirs();
    const rows = this.db.prepare('SELECT id FROM notes').all() as Array<{ id: string }>;
    const known = new Set(rows.map((r) => r.id));
    let removed = 0;
    for (const d of dirs) {
      if (!known.has(d)) { await this.store.deleteNoteDirectory(d); removed += 1; }
    }
    return { removed };
  }
}
  • Step 2: Commit
git add src/main/services/MediaGc.ts
git commit -m "feat(media): MediaGc for orphan dir cleanup on startup"

Task 32: E2E smoke test

Files: Create playwright.config.ts, tests/e2e/smoke.spec.ts

  • Step 1: Playwright config
import { defineConfig } from '@playwright/test';
export default defineConfig({
  testDir: './tests/e2e',
  timeout: 60_000,
  retries: 0,
  workers: 1,
  reporter: 'list'
});
  • Step 2: Smoke test
import { test, expect, _electron as electron } from '@playwright/test';
import { resolve } from 'node:path';

test('inbox shell shows v0.2 empty state', async () => {
  const app = await electron.launch({
    args: [resolve('out/main/index.js')],
    env: { ...process.env, INKLING_DEBUG: '1' }
  });
  const inbox = await app.firstWindow();
  await inbox.waitForLoadState('domcontentloaded');
  await expect(inbox.getByText('Inkling')).toBeVisible();
  await expect(inbox.getByText('첫 기억을 구출해보세요.')).toBeVisible();
  await app.close();
});
  • Step 3: Build + run
npm run build
npx playwright install chromium
npm run test:e2e
  • Step 4: Commit
git add playwright.config.ts tests/e2e/smoke.spec.ts
git commit -m "test(e2e): smoke test verifying v0.2 inbox empty state"

Task 33: Full verification pass

  • Step 1: Run unit tests

npm test → all pass.

  • Step 2: Typecheck

npm run typecheck → exit 0.

  • Step 3: Manual dogfood checklist (Strategy + base flows)

Ensure gemma4:e4b is reachable at the endpoint defined by INKLING_OLLAMA_ENDPOINT (or http://localhost:11434 if unset). Then:

  • Press Ctrl+Shift+J from another app → QuickCapture opens within ~100ms with placeholder "지금 머릿속에 있는 것 한 줄. 정리는 나중입니다."

  • Type "회의 중 A프로젝트 API 타임아웃 재발. 재현 로그 확보 예정." → Ctrl+Enter → window closes.

  • OS native notification appears within 1s with one of the 4 reward copies (e.g. "이 생각은 이제 Inkling이 들고 있습니다.").

  • Inbox shows new card with "Inkling이 정리하는 중…" and raw text expanded.

  • Within ~30s the card flips to done with Korean title (with "AI" gray badge), 3-line Korean summary (with "AI" gray badge), kebab-case tags (each with "AI" subscript).

  • An IntentBanner appears at the top of the card with one of the 4 rotating prompts.

  • Type "다시 만나면 trace 먼저 확인" + Enter → banner disappears, "💡 다시 만나면 trace 먼저 확인" badge appears on card permanently.

  • On a separate note: click "건너뛰기" instead → banner disappears and never returns.

  • Click the title → edit → blur → "AI" badge on title disappears (titleEditedByUser=1).

  • Click a tag → it disappears.

  • Paste a screenshot into QuickCapture and submit → thumbnail shown on card.

  • Stop Ollama → new submissions accumulate as pending; OllamaBanner appears with "Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요."

  • Restart Ollama and app → pending jobs resume and complete.

  • ContinuityBadge shows "이번 주 한 줄이면 시작입니다" before any note today; flips to "이번 주 1/7" then "이번 주 2/7" etc; at 7 shows "이번 주 7/7 ✓ · 연속 K주 완성".

  • Simulate 8+ day gap (set system clock or insert old data manually): submit one note → "🌱 흐름을 다시 이어갑니다" banner appears for that day.

  • Close Inbox window → app stays alive in tray; tray "종료" exits.

  • Step 4: If all green, tag

git tag v0.2.0-slice

Notes for the Engineer

  • TDD discipline: every business-logic task is Red → Green → Commit. Run failing tests once before implementing — you must see the assertion fail to trust it.
  • Frequent commits: one per task minimum. Do not squash.
  • Logs and PII: never log raw_text, ai_title, ai_summary, or user_intent content. Only IDs, lengths, and hash prefixes.
  • Original preservation: NoteRepository has no method to mutate raw_text. AI re-runs do not overwrite user-edited title/summary (guarded by *_edited_by_user columns).
  • Intent flow: the IntentBanner appears exactly once per note (gate: intent_prompted_at IS NULL). Both setIntent and dismissIntent close the gate.
  • Continuity copy: the words "실패", "끊김", "연속 실패" are forbidden in any UI string in this slice. If a future contributor adds them, fail review.
  • Native notifications: if the OS denies permission, swallow silently — capture must succeed regardless. Do not block submit on notification result.
  • Recovery toast: dismissal persists in renderer localStorage keyed to KST date. Re-shown on a new recovery event (different date).
  • Versions: if npm install surfaces a newer stable major than spec §7.2, update both package.json and the spec in the same PR.