v0.2.7 release 후 dogfood 9건 누적 (F17~F25) 정리: - F17 휴지통 의미 분기 / F18 사유 입력 / F19 recall / F20 raw_text 가변 - F21 다기기 sync / F22 이미지 렌더링 (이미 v0.2.8 promoted) / F23 Ollama-less - F24 멀티모달 vision / F25 사이드바 + 저장소 추가: - v0.2.8+ roadmap: 7 cut 분할 (A~G), 12주 시간선, dependency graph - Cut A~G design specs (각 cut 별 design 결정 + schema + UI + 테스트 전략) - Cut A implementation plan (이미 v0.2.8 머지로 실행 완료, 참고 보존) PR #26 머지 후 main 에 doc commits rebase 안 되어 manual merge 진행: - F22 entry 는 origin/main 의 promoted 형태 우선 - 신규 9 파일 (specs/plan/roadmap) 은 origin/main 에 없는 파일 - "다음 항목 자리" 안내 F23 → F26 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
22 KiB
v0.2.8 Cut A Implementation Plan — 이미지 렌더링 + 앱 아이콘
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: F22 (NoteCard 의 회색 placeholder → 실제 <img> + 클릭 시 OS viewer) + chore (앱 아이콘 SVG → ICO/ICNS/PNG 다중 size + electron-builder 통합).
Architecture: Electron renderer 보안 정책 우회를 위해 main process 에 inkling-media:// custom protocol 등록 — <profileDir>/media/<noteId>/<filename> 을 fetch 가능하게 함. NoteCard 가 protocol URL 을 <img src> 로 사용. 클릭 시 IPC inbox:open-media → shell.openPath 로 OS default viewer 열기. 앱 아이콘은 assets/icon.svg (이미 작성) 를 electron-icon-builder 로 한 번 빌드 → build/icon.ico/icns/png 산출물 git 추적 + electron-builder config 매핑.
Tech Stack: Electron 41 protocol API + React 19 + better-sqlite3 + electron-icon-builder + sharp (SVG 변환 fallback)
선행 spec: docs/superpowers/specs/2026-05-09-v028-cut-a-design.md
File Structure
신규 파일
| 경로 | 책임 |
|---|---|
src/main/protocol/inklingMedia.ts |
inkling-media:// scheme 권한 + handler 등록 (path traversal 검사 + inferMime) |
tests/unit/inklingMedia.test.ts |
protocol handler 단위 테스트 (path traversal 403 / 정상 200 / 404 / mime) |
assets/icon.svg |
(이미 v0.2.7 turn 에서 생성 — Cut A 시작 전 commit 필요 시 재확인) |
수정 파일
| 경로 | 변경 |
|---|---|
src/main/index.ts |
top-level protocol.registerSchemesAsPrivileged + whenReady 안 registerInklingMediaProtocol(paths.profileDir) 호출 + registerInboxApi 가 신규 IPC 채널 등록 |
src/main/ipc/inboxApi.ts |
신규 IPC inbox:open-media 핸들러 (path traversal 검사 + shell.openPath) |
src/preload/index.ts |
inbox:open-media 채널 화이트리스트 |
src/shared/types.ts |
InboxApi 에 openMedia(relPath: string): Promise<{ ok: boolean; reason?: string }> 시그니처 추가 |
src/renderer/inbox/api.ts |
inboxApi 객체에 openMedia wrapper |
src/renderer/inbox/components/NoteCard.tsx |
회색 <div> placeholder → <img src="inkling-media://..."> + onClick |
tests/unit/NoteCard.test.tsx |
신규 또는 추가 — <img> 렌더 + 클릭 시 IPC 호출 |
package.json |
devDep electron-icon-builder + script build:icons + build.win/mac/linux.icon 경로 |
.gitignore |
build/ 안 icon.ico/icns/png 만 추적 (whitelist) |
build/icon.ico / build/icon.icns / build/icon.png |
빌드 산출물 commit |
docs/superpowers/specs/2026-04-25-dogfood-feedback.md |
F22 진행 상태 🚀 promoted 마킹 |
docs/superpowers/v024-backlog.md |
(해당 없음 — Cut A 의 작업이 backlog # 와 매핑 X) |
Phase 개요
Phase 1: F22 inkling-media protocol (Task 1)
Phase 2: F22 NoteCard <img> + 클릭 (Task 2)
Phase 3: F22 IPC inbox:open-media (Task 3)
Phase 4: chore 앱 아이콘 빌드 + config (Task 4)
Phase 5: verification + version bump (Task 5)
Phase 1: inkling-media protocol
Task 1: protocol 등록 + handler + 단위 테스트
Files:
-
Create:
src/main/protocol/inklingMedia.ts -
Create:
tests/unit/inklingMedia.test.ts -
Modify:
src/main/index.ts -
Step 1: failing test 작성
// tests/unit/inklingMedia.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join, sep } from 'node:path';
const mockReadFile = vi.fn();
vi.mock('node:fs/promises', () => ({ readFile: mockReadFile }));
const mockHandle = vi.fn();
vi.mock('electron', () => ({
default: {
protocol: {
registerSchemesAsPrivileged: vi.fn(),
handle: mockHandle
}
}
}));
import { registerInklingMediaProtocol, inferMime } from '../../src/main/protocol/inklingMedia';
describe('inferMime', () => {
it('returns image/png for .png', () => { expect(inferMime('foo.png')).toBe('image/png'); });
it('returns image/jpeg for .jpg and .jpeg', () => {
expect(inferMime('foo.jpg')).toBe('image/jpeg');
expect(inferMime('foo.jpeg')).toBe('image/jpeg');
});
it('returns image/gif for .gif', () => { expect(inferMime('foo.gif')).toBe('image/gif'); });
it('returns image/webp for .webp', () => { expect(inferMime('foo.webp')).toBe('image/webp'); });
it('returns application/octet-stream for unknown', () => { expect(inferMime('foo.xyz')).toBe('application/octet-stream'); });
});
describe('inkling-media protocol handler', () => {
beforeEach(() => { vi.clearAllMocks(); });
function getHandler(profileDir: string): (req: Request) => Promise<Response> {
registerInklingMediaProtocol(profileDir);
return mockHandle.mock.calls[0][1];
}
it('serves valid file with correct mime', async () => {
mockReadFile.mockResolvedValueOnce(Buffer.from([1, 2, 3]));
const handler = getHandler('/profile');
const res = await handler(new Request('inkling-media://media/note1/img.png'));
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png');
expect(mockReadFile).toHaveBeenCalledWith(join('/profile', 'media', 'note1', 'img.png'));
});
it('returns 403 on path traversal attempt', async () => {
const handler = getHandler('/profile');
const res = await handler(new Request('inkling-media://media/../etc/passwd'));
expect(res.status).toBe(403);
expect(mockReadFile).not.toHaveBeenCalled();
});
it('returns 404 when file missing', async () => {
mockReadFile.mockRejectedValueOnce(new Error('ENOENT'));
const handler = getHandler('/profile');
const res = await handler(new Request('inkling-media://media/note1/missing.png'));
expect(res.status).toBe(404);
});
});
- Step 2: 테스트 실행 → fail
npm run rebuild:node
npx vitest run tests/unit/inklingMedia.test.ts
Expected: Cannot find module '../../src/main/protocol/inklingMedia'.
- Step 3: protocol handler 작성
// src/main/protocol/inklingMedia.ts
import electron from 'electron';
import { promises as fs } from 'node:fs';
import { join, normalize, sep, extname } from 'node:path';
const { protocol } = electron;
export function registerSchemesAsPrivileged(): void {
protocol.registerSchemesAsPrivileged([
{ scheme: 'inkling-media', privileges: { secure: true, supportFetchAPI: true, stream: true } }
]);
}
export function inferMime(filename: string): string {
const ext = extname(filename).toLowerCase();
switch (ext) {
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.gif': return 'image/gif';
case '.webp': return 'image/webp';
default: return 'application/octet-stream';
}
}
export function registerInklingMediaProtocol(profileDir: string): void {
const mediaRoot = join(profileDir, 'media');
protocol.handle('inkling-media', async (req) => {
const url = new URL(req.url);
// host + pathname 합쳐서 relPath 구성. inkling-media://media/<noteId>/<file> 형식.
// URL parse 시 host = 'media', pathname = '/<noteId>/<file>'
const relPath = decodeURIComponent((url.host + url.pathname).replace(/^media\//, 'media/'));
const target = normalize(join(profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return new Response(null, { status: 403 });
}
try {
const data = await fs.readFile(target);
return new Response(new Uint8Array(data), {
headers: { 'content-type': inferMime(target) }
});
} catch {
return new Response(null, { status: 404 });
}
});
}
- Step 4: 테스트 통과 확인
npx vitest run tests/unit/inklingMedia.test.ts
Expected: 모든 test pass.
- Step 5: src/main/index.ts 통합
src/main/index.ts 의 import 추가:
import { registerSchemesAsPrivileged, registerInklingMediaProtocol } from './protocol/inklingMedia.js';
top-level (whenReady 이전, app 생성 직후) 에서:
registerSchemesAsPrivileged();
whenReady 안에서 (paths 초기화 후):
registerInklingMediaProtocol(paths.profileDir);
- Step 6: typecheck + 전체 회귀
npm run typecheck
npx vitest run
Expected: 0 errors + 460 → 466 (+6 inferMime 5 + handler 3).
- Step 7: commit
git add src/main/protocol/inklingMedia.ts tests/unit/inklingMedia.test.ts src/main/index.ts
git commit -m "feat(v028): inkling-media:// custom protocol + path traversal 검사"
Phase 2: NoteCard <img> + 클릭
Task 2: NoteCard placeholder → <img> 교체
Files:
-
Modify:
src/renderer/inbox/components/NoteCard.tsx -
Create:
tests/unit/NoteCard.test.tsx(없으면 신규) -
Step 1: failing test 작성
// tests/unit/NoteCard.test.tsx
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import type { Note } from '@shared/types';
const mockOpenMedia = vi.fn(async () => ({ ok: true }));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
openMedia: mockOpenMedia,
// 다른 inboxApi 메서드 stub (NoteCard 가 사용할 수 있음)
permanentDeleteNote: vi.fn(),
restoreNote: vi.fn()
}
}));
import { NoteCard } from '../../src/renderer/inbox/components/NoteCard';
const baseNote: Note = {
id: 'n1',
rawText: 'test',
title: 'T',
summary: '',
tags: [],
aiStatus: 'complete',
createdAt: '2026-05-09T00:00:00Z',
updatedAt: '2026-05-09T00:00:00Z',
deletedAt: null,
media: [
{ id: 'm1', relPath: 'media/n1/img1.png', mime: 'image/png' },
{ id: 'm2', relPath: 'media/n1/img2.jpg', mime: 'image/jpeg' }
]
} as unknown as Note;
describe('NoteCard — image rendering', () => {
beforeEach(() => { vi.clearAllMocks(); cleanup(); });
it('renders <img> for each media item', () => {
render(<NoteCard note={baseNote} isTrash={false} />);
const imgs = screen.getAllByRole('img');
expect(imgs).toHaveLength(2);
expect(imgs[0].getAttribute('src')).toBe('inkling-media://media/n1/img1.png');
expect(imgs[1].getAttribute('src')).toBe('inkling-media://media/n1/img2.jpg');
});
it('clicking <img> calls inboxApi.openMedia', () => {
render(<NoteCard note={baseNote} isTrash={false} />);
fireEvent.click(screen.getAllByRole('img')[0]);
expect(mockOpenMedia).toHaveBeenCalledWith('media/n1/img1.png');
});
});
- Step 2: 테스트 실행 → fail
npx vitest run tests/unit/NoteCard.test.tsx
Expected: getAllByRole('img') 미존재 (현재 회색 div 만).
- Step 3: NoteCard 갱신
src/renderer/inbox/components/NoteCard.tsx:334-340 의 회색 placeholder div 부분을 다음으로 교체:
{local.media.length > 0 && (
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{local.media.map((m) => (
<img
key={m.id}
src={`inkling-media://${m.relPath}`}
alt=""
title={m.relPath}
onClick={() => { void inboxApi.openMedia(m.relPath); }}
style={{
width: 48,
height: 48,
objectFit: 'cover',
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #e0e0e0'
}}
/>
))}
</div>
)}
inboxApi import 가 이미 있는지 확인 — 있으면 그대로. 없으면 추가:
import { inboxApi } from '../api.js';
- Step 4: 테스트 통과 + typecheck
npx vitest run tests/unit/NoteCard.test.tsx
npm run typecheck
Expected: 2 test pass (openMedia 가 types.ts 에 아직 없으니 typecheck error 가능 — Task 3 에서 해결). 일단 임시로 NoteCard.test.tsx 의 mock 만 동작.
만약 typecheck error 발생: inboxApi.openMedia 가 InboxApi 인터페이스에 없음 → 일시 (inboxApi as any).openMedia(m.relPath) 로 cast. Task 3 에서 정식 시그니처 추가 후 cast 제거.
- Step 5: commit (with TODO note for Task 3)
git add src/renderer/inbox/components/NoteCard.tsx tests/unit/NoteCard.test.tsx
git commit -m "feat(v028): NoteCard 이미지 <img> 렌더링 + onClick (openMedia 시그니처는 Task 3)"
Phase 3: IPC inbox:open-media
Task 3: main IPC 핸들러 + api.ts wrapper + types
Files:
-
Modify:
src/main/ipc/inboxApi.ts -
Modify:
src/preload/index.ts -
Modify:
src/shared/types.ts -
Modify:
src/renderer/inbox/api.ts -
Modify:
src/renderer/inbox/components/NoteCard.tsx(Task 2 cast 제거) -
Step 1: failing test 작성 (IPC handler 단위)
// tests/unit/inboxApi-openMedia.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { join, sep } from 'node:path';
const handlers: Record<string, Function> = {};
const mockOpenPath = vi.fn(async () => '');
vi.mock('electron', () => ({
default: {
ipcMain: { handle: (ch: string, fn: Function) => { handlers[ch] = fn; } },
shell: { openPath: mockOpenPath }
}
}));
import { registerInboxApi } from '../../src/main/ipc/inboxApi';
describe('inbox:open-media IPC', () => {
beforeEach(() => { vi.clearAllMocks(); for (const k of Object.keys(handlers)) delete handlers[k]; });
it('opens valid relPath with shell.openPath', async () => {
// 기존 registerInboxApi 가 deps 받으므로 minimal stub 만 — paths 만 사용
registerInboxApi({ paths: { profileDir: '/profile' } } as any);
const r = await handlers['inbox:open-media'](null, 'media/note1/img.png');
expect(r).toEqual({ ok: true });
expect(mockOpenPath).toHaveBeenCalledWith(join('/profile', 'media/note1/img.png'));
});
it('rejects path traversal', async () => {
registerInboxApi({ paths: { profileDir: '/profile' } } as any);
const r = await handlers['inbox:open-media'](null, '../etc/passwd');
expect(r.ok).toBe(false);
expect(r.reason).toBe('invalid path');
expect(mockOpenPath).not.toHaveBeenCalled();
});
});
- Step 2: 테스트 실행 → fail
npx vitest run tests/unit/inboxApi-openMedia.test.ts
Expected: handler 미등록.
- Step 3: src/main/ipc/inboxApi.ts 핸들러 추가
기존 registerInboxApi(deps) 함수 안에 다음 추가 (다른 IPC 핸들러와 같은 패턴):
import { join, normalize, sep } from 'node:path';
import electron from 'electron';
const { ipcMain, shell } = electron;
// registerInboxApi 안:
ipcMain.handle('inbox:open-media', async (_e, relPath: string) => {
const mediaRoot = join(deps.paths.profileDir, 'media');
const target = normalize(join(deps.paths.profileDir, relPath));
if (!target.startsWith(mediaRoot + sep) && target !== mediaRoot) {
return { ok: false, reason: 'invalid path' };
}
await shell.openPath(target);
return { ok: true };
});
(기존 deps 타입에 paths.profileDir 가 있는지 확인. 없으면 SettingsIpcDeps 와 비슷한 형태로 추가.)
- Step 4: src/shared/types.ts InboxApi 갱신
// 기존 InboxApi 인터페이스 안:
openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>;
- Step 5: preload + api.ts wrapper
src/preload/index.ts 의 invoke whitelist 또는 API expose 객체 안에 'inbox:open-media' 추가 (기존 'inbox:*' 채널 패턴 따름).
src/renderer/inbox/api.ts 의 inboxApi 객체에 추가:
async openMedia(relPath: string) {
return await window.inkling.invoke('inbox:open-media', relPath);
}
(또는 기존 wildcard re-export 사용 시 자동 노출 — feb7c62 commit 의 onNavigate 처럼.)
- Step 6: NoteCard.tsx cast 제거
Task 2 에서 임시 (inboxApi as any).openMedia cast 했다면 → inboxApi.openMedia 정상 사용으로 변경. typecheck 통과 확인.
- Step 7: 테스트 + typecheck + 회귀
npx vitest run tests/unit/inboxApi-openMedia.test.ts
npm run typecheck
npx vitest run
Expected: IPC 2 test pass + 466 → 468 (+2) + 0 typecheck.
- Step 8: commit
git add -A src/main/ipc/ src/preload/ src/shared/ src/renderer/ tests/unit/inboxApi-openMedia.test.ts
git commit -m "feat(v028): IPC inbox:open-media + path traversal + NoteCard cast 정리"
Phase 4: 앱 아이콘 빌드 + config
Task 4: electron-icon-builder + 산출물 + builder config
Files:
-
Modify:
package.json -
Modify:
.gitignore -
Create:
build/icon.ico,build/icon.icns,build/icon.png(빌드 산출물) -
(조건부) Create:
scripts/svg-to-png.mjs(SVG → PNG fallback if needed) -
Step 1: devDep 설치
npm install --save-dev electron-icon-builder
확인: package.json 의 devDependencies 에 "electron-icon-builder": "^2.x.x" 추가됨.
- Step 2: package.json scripts + builder config
package.json 의 scripts 블록에 추가:
"build:icons": "electron-icon-builder --input=assets/icon.svg --output=build --flatten"
(만약 SVG 직접 input 안 되면 — Step 3 에서 sharp fallback 으로 분기.)
build 블록 갱신 (기존 win/mac/linux 에 icon 키 추가):
"win": { "icon": "build/icon.ico", ... 기존 그대로 ... },
"mac": { "icon": "build/icon.icns", ... 기존 그대로 ... },
"linux": { "icon": "build/icon.png", ... 기존 그대로 ... }
- Step 3: 빌드 실행
npm run build:icons
Expected:
build/icon.ico(Win)build/icon.icns(macOS)build/icon.png(1024x1024, Linux)
만약 SVG 직접 안 되면 (electron-icon-builder 가 PNG 만 input 받음):
scripts/svg-to-png.mjs작성:
import sharp from 'sharp';
import { readFileSync, writeFileSync } from 'node:fs';
const [,, input, output, size = '1024'] = process.argv;
const svg = readFileSync(input);
const png = await sharp(svg).resize(Number(size), Number(size)).png().toBuffer();
writeFileSync(output, png);
console.log(`OK: ${output} (${size}x${size})`);
package.jsonscripts 갱신:
"build:icons:png": "node scripts/svg-to-png.mjs assets/icon.svg build/icon-source.png 1024",
"build:icons": "npm run build:icons:png && electron-icon-builder --input=build/icon-source.png --output=build --flatten"
-
npm install --save-dev sharp(이미 있으면 skip). -
다시
npm run build:icons실행.
- Step 4: .gitignore 갱신 (build/ 안 산출물 whitelist)
.gitignore 의 build/ 또는 dist/ 항목 확인. 만약 build/ 가 ignore 되어 있다면:
build/
!build/icon.ico
!build/icon.icns
!build/icon.png
(만약 build/ 가 ignore 안 되어 있다면 — 모두 추적 가능 — Step 4 skip.)
- Step 5: 산출물 확인 + commit
ls -la build/icon.ico build/icon.icns build/icon.png
Expected: 3 파일 모두 size 양수 (수십 KB 이상).
git add package.json package-lock.json .gitignore scripts/svg-to-png.mjs build/icon.ico build/icon.icns build/icon.png
git commit -m "chore(v028): 앱 아이콘 (assets/icon.svg → ICO/ICNS/PNG) + electron-builder config"
(scripts/svg-to-png.mjs 는 sharp fallback 사용 시만 추가.)
- Step 6: typecheck + 전체 회귀
npm run typecheck
npx vitest run
Expected: 0 errors + 468 pass (이전 + Task 1-3 변경 포함). 아이콘 변경은 테스트 영향 X.
Phase 5: verification + version bump
Task 5: 회귀 + dogfood-feedback 마킹 + version bump
Files:
-
Modify:
docs/superpowers/specs/2026-04-25-dogfood-feedback.md(F22 promoted 마킹) -
Modify:
package.json(version 0.2.7 → 0.2.8) -
Step 1: 단위 + e2e + typecheck 일괄
npm run rebuild:node
npm test
npm run typecheck
npm run rebuild:electron
npm run test:e2e
Expected: 모두 pass. 단위 460 → 약 468 (+8: inferMime 5 + protocol handler 3 + NoteCard img 2 + IPC 2 = 12, 일부 mock 충돌 가능 — 실제 카운트 ±). e2e 1/1.
- Step 2: 수동 launch 검증 (Win + macOS 가능 시)
npm run start
체크리스트:
-
inbox 의 capture-with-image 노트의 thumbnail = 실제 이미지 (회색 사각형 X)
-
thumbnail 클릭 → OS default viewer (예: Win Photos / macOS Preview) 열림
-
새 아이콘이 트레이 / Windows taskbar / dock 정확 표시
-
다중 이미지 노트의 grid layout (flex-wrap) 자연스러움
-
Step 3: dogfood-feedback.md F22 promoted 마킹
docs/superpowers/specs/2026-04-25-dogfood-feedback.md 의 F22 entry 헤더 갱신:
## F22. NoteCard 이미지가 회색 placeholder 만 표시 (🚀 promoted → docs/superpowers/specs/2026-05-09-v028-cut-a-design.md)
진행 상태 line 도 🚀 promoted 로 갱신.
- Step 4: package.json version bump
"version": "0.2.8"
- Step 5: commit
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json
git commit -m "chore(release): v0.2.8 — Cut A (이미지 렌더링 + 앱 아이콘)"
Self-Review
Spec coverage:
| Spec 섹션 | task |
|---|---|
| §3-1 protocol 등록 | Task 1 |
§3-2 NoteCard <img> |
Task 2 |
| §3-3 IPC inbox:open-media | Task 3 |
| §3-4 보안 (path traversal) | Task 1 + Task 3 |
| §4-1 devDep + scripts | Task 4 |
| §4-2 builder config | Task 4 |
| §4-3 산출물 git 추적 | Task 4 |
| §4-4 SVG → PNG fallback | Task 4 (조건부) |
| §5 테스트 | 각 task 안 단위 + Task 5 회귀 |
| §6 Risk | Task 4 fallback 분기 |
모든 spec 요구가 task 매핑됨.
Placeholder scan: "TBD" / "TODO" / "implement later" 없음. 각 step 의 코드/명령 실행 가능 형태.
Type consistency:
InboxApi.openMedia(relPath: string): Promise<{ ok: true } | { ok: false; reason: string }>— Task 3 정의, Task 2 cast → Task 3 cast 제거 일관.inferMime(filename: string): string— Task 1 정의, Task 1 안 사용.registerInklingMediaProtocol(profileDir: string): void/registerSchemesAsPrivileged(): void— Task 1 export, src/main/index.ts import 일관.
이슈 없음.
Execution Handoff
Plan 작성 완료, docs/superpowers/plans/2026-05-09-v028-cut-a.md 저장.
두 가지 실행 옵션:
1. Subagent-Driven (recommended) — fresh subagent per task, two-stage review (spec compliance + code quality), 빠른 iteration
2. Inline Execution — 본 세션에서 task 일괄 실행 + checkpoint 마다 review
어느 쪽?