Running a custom instruction (translate, summarise, rewrite, explain code,
free prompt) inserted the instruction's own wording instead of the result.
Two faults stacked:
The instruction was passed as the text to process, leaving the system-prompt
argument empty. `BASE_SYSTEM_PROMPTS` has no `custom` key, so resolution fell
back to `refine` without saying so, and the model dutifully polished the
instruction it had been handed. The transcript never reached it.
And only `{{text}}` was substituted, which none of the five built-in
instructions use — they carry `{{targetLanguage}}`, `{{userPrompt}}`, or no
placeholder at all. The substitution was a no-op from the day it was written:
the presets landed ten hours before the code that expected them.
- Instruction prompts now go to the system-prompt argument and the transcript
to the text argument. Instructions that spell out `{{text}}` keep their old
meaning, so hand-written ones still work.
- `renderInstructionPrompt` resolves `{{text}}`, `{{userPrompt}}` and
`{{targetLanguage}}` in one place, and warns by name when a placeholder is
left standing rather than letting it reach the model.
- `resolveSystemPrompt` no longer drops silently to `refine` for `custom`.
- Voice shortcuts no longer die at the `defaultLLMAction === 'none'` gate; an
explicitly named instruction outranks the default. Without one, `none` still
passes the transcript through untouched.
- `translate` receives its target language instead of relying on a default two
call frames away. It is still always English — `AppConfig` has no key for it,
and neither `language` (UI locale) nor `sttLanguage` (source language) can
stand in. Choosing a target language needs a setting and is not in this fix.
- Chains ran instructions with placeholders intact; they share the same
resolution now.
- The command screen's pipeline bench called `llm.generate`, which preload does
not expose, so every run threw and the catch showed the input back as if it
had succeeded. It uses `llm.process` now, over the same path production
takes, and a failure reads as a failure.
Present since the feature shipped: the custom-instruction path has never
worked. Plain actions (refine, summarise, grammar, expand) were unaffected and
are now covered by tests so they stay that way.
44 KiB
11 — Gap & Backlog Register
The maintained list of what is under-developed, deferred, or externally blocked. Status: living document. Every feature change updates this file (see
12-update-protocol.md). External items are markedEXT; they block "done" but must not block code, tests, or local fixtures.
Legend: [ ] open · [~] in progress · [!] blocked externally · [x] resolved (keep for one cycle, then prune).
0. How to read this
- An item here is not a failure. It is a known state with an owner and a next step.
- When you close an item, flip it to
[x], add the date + evidence path, and also update10-feature-catalog.md. - Grandfathered detail lives in
docs/v3/MOBILE_APP_COMPLETION_SSOT.md; this file is the cross-surface roll-up. When the two disagree, the SSOT wins for mobile and must be reconciled here. - Not everything imperfect is a gap. Trade-offs that were reviewed and deliberately kept live in §7 as constraints, not in §1. Check §7 before opening a row for one.
1. High impact — real capability gaps (no external blocker)
| ID | Area | Gap | Evidence | Suggested next step |
|---|---|---|---|---|
| GAP-QA-01 | Quality | Extreme Red Team: headful end-to-end bug hunting across real desktop Electron, Web Next.js, and CI pipelines. | red_team_log.md, tests/e2e/red_team_cycle*.spec.ts, apps/web/e2e/red_team_cycle4_web.spec.ts |
[x] 2026-09-15: 18 scenarios executed, 14 defects caught and 100% resolved (infinite chunking loop DEF-008, IPC signature mismatch DEF-004, markdown editor typing rollback DEF-006, Web RSC Link serialization DEF-012, secret scanner lookahead DEF-013, etc.). All 18 scenarios GREEN with zero regressions. |
| GAP-REL-01 | Release | Official release publication to Forgejo and active public download center deployment. | scripts/ci/publish-forgejo-release.mjs, apps/web/src/app/download/page.tsx, site/src/sections/Download.tsx, apps/web/e2e/red_team_cycle4_web.spec.ts |
[~] 2026-09-15: v1.1.0 release assets (D3RO-Voice-Setup-1.1.0-x64.exe, .blockmap, latest.yml, update-policy.json) published to canonical Forgejo registry and release hub. 2026-09-16: the published 1.1.0 installer carries no Authenticode signature, so it does not satisfy the release policy; product version moved to 1.2.0 and publication must come from CI with the signing gate GREEN. Download centers in apps/web (/download) and site (#download) link the canonical Forgejo feed. 2026-09-19: 1.3.7 (overlay fix) is published to the canonical updater feed (latest.yml = 1.3.7, 90.6MiB); the CI signing gate that blocks tag-driven publication is still unresolved, so this went out through the local updater path (GAP-REL-06). |
| GAP-REL-08 | Release | 다운로드 센터가 존재하지 않는 설치 파일을 가리켰다. apps/web/src/lib/desktop-release.ts와 site/src/release.ts의 DESKTOP_VERSION이 1.2.0에 멈춰 있어 설치 URL이 D3RO-Voice-Setup-1.2.0-x64.exe였고, 그 경로는 피드에서 404다(실측: 1.2.0=404, 1.3.7=206). version:sync가 이 두 표면을 덮지 않아 계속 어긋났다. |
scripts/ci/sync-version.mjs, apps/web/src/lib/desktop-release.ts, site/src/release.ts |
[x] 2026-09-19: 두 다운로드 계약 파일을 sync-version.mjs 대상에 추가해 버전·릴리스일이 SSOT에서 자동 반영되도록 하고, 현재 값(1.3.7 / 2026-09-19)으로 정정했다. version:check·typecheck·site 빌드 GREEN. |
| GAP-REL-09 | Release | 랜딩 사이트가 재배포되지 않는다. deploy 워크플로가 main push마다 실패한다. 실측 원인(run#66 로그): site/src/sections/Hero.tsx가 타이머 ref를 NodeJS.Timeout으로 타이핑해 @types/node 네임스페이스가 필요했고, 배포 잡은 npm ci --prefix site만 하므로 조상 node_modules의 hoisted 타입이 없어 tsc -b가 TS2503: Cannot find namespace 'NodeJS'로 실패한다. 그래서 https://d3ro.chanpaca.net/release-identity.json이 404다(공개 버전 검증 불가). |
.forgejo/workflows/deploy-site.yml, site/src/sections/Hero.tsx |
[x] 2026-09-19: ref를 ReturnType<typeof setTimeout>으로 바꿔 hoisted 타입 의존을 제거했다(격리 --typeRoots로 CI 조건 재현 → 수정 전 TS2503, 수정 후 clean). 같은 수정을 push하자 deploy run#67이 사이트 빌드를 통과해 dist/를 만들었고, 실패는 다음 단계(Cloudflare)로 이동했다. |
| GAP-REL-09b | Release | d3ro.chanpaca.net이 404였던 직접 원인: 이 Cloudflare 계정에 Pages 프로젝트 d3ro/d3ro-voice가 존재하지 않아 커스텀 도메인 바인딩이 없었다(빈 본문 404, cf-ray만 반환). Pages 커스텀 도메인은 존 DNS CNAME(d3ro → d3ro.pages.dev)을 요구하는데 기존 d3ro 레코드가 남아 있어 CNAME record not set으로 pending에 머물렀고, 로컬 wrangler 자격증명에는 DNS 스코프가 없다(403 Authentication error). deploy-site.yml은 CF_API_TOKEN 시크릿이 없어 마지막 게시 단계에서도 exit 1이다. |
server/cloudflare-site-bridge/, .forgejo/workflows/deploy-site.yml, docs/map/02-infrastructure.md |
[x] 2026-09-19: Pages 프로젝트 d3ro 생성 + site/dist production 배포(d3ro.pages.dev 200, release-identity.json = commit 2407f5a / 1.3.7) + 커스텀 도메인 연결. DNS 없이 도메인을 살리기 위해 Workers 라우트 브리지(server/cloudflare-site-bridge, d3ro.chanpaca.net/* → Pages 프록시, npx wrangler deploy)를 배포 → 라이브 확인: /·/privacy/·/terms/·/delete-account/ 200, 라이브 번들이 설치 파일명을 1.3.7로 계산, /download.html → /#download. 남은 정리 2건: (1) 대시보드에 CNAME을 추가한 뒤 브리지 워커 삭제, (2) CI 자동 게시를 위해 CF_API_TOKEN(Pages/Workers Edit) + CF_ACCOUNT_ID=8e83cc130e7329c160cf2b88d6b4c20a를 Forgejo 시크릿에 등록. |
| GAP-REL-10 | Release | release-windows(태그 파이프라인)는 서명 가드에 도달하기 전에 sidecar 단계에서 죽는다. 이 러너 컨텍스트에서는 sidecar:setup이 Python 3.11+를 찾지 못한다(Python 3.11+ 를 찾을 수 없습니다) → sidecar:build → verify-sidecar-bundle.mjs 연쇄 실패(실측: run#65 v1.3.7, run#61 v1.3.6). 같은 러너의 portable 잡은 py -3.11 → Python 3.11.9를 찾아 사이드카 빌드에 성공하므로, 워크플로/컨테이너 간 PATH 차이다. |
.forgejo/workflows/release.yml, apps/desktop/scripts/setup-sidecar.mjs |
[!] 2026-09-19: 러너에 Python 3.11+(py 런처 포함)를 보장하거나 워크플로에 actions/setup-python 단계를 추가한다. 그 전까지 서명 게시는 불가능하다(GAP-REL-02와 별개 선행 차단). |
| GAP-REL-11 | Release | portable 워크플로의 마지막 actions/upload-artifact@v4 단계가 Forgejo 러너에서 GHESNotSupportedError로 실패한다(증거 보존만 실패, 게시는 성공). |
.forgejo/workflows/portable.yml |
[x] 2026-09-19: v1.3.7 portable 게시는 run#64에서 성공(7z 단일 볼륨 83.7MB + zip 2부, portable-latest/portable.json이 1.3.7 보고). 남은 조치: upload-artifact 단계를 제거하거나 v3/다른 보존 방식으로 바꿔 워크플로를 GREEN으로 만든다. |
| GAP-REL-02 | Release | Windows stable publication needs an external public-trust Authenticode PFX, its password, the exact signer subject, and a Forgejo token, none of which live in the repository. | .forgejo/workflows/release.yml, .gitlab-ci.yml, scripts/ci/set-forgejo-secrets.mjs, scripts/ci/verify-windows-release-artifact.ps1 |
[!] 2026-09-18 measured: the Forgejo repo had zero Actions secrets; FORGEJO_TOKEN is registered now (2026-09-18) but WIN_CSC_* still have no values, so v1.2.0 (run 49) and v1.3.0 (run 51) both failed at the signing guard and no updater-feed release has been published since 1.1.0. Inject the four secrets (WIN_CSC_LINK, WIN_CSC_KEY_PASSWORD, WIN_CSC_EXPECTED_SIGNER_SUBJECT, FORGEJO_TOKEN) with npm run release:secrets (check: npm run release:secrets:check), then re-run release.yml for the v1.3.0 tag via workflow_dispatch (tags are immutable). 2026-09-19 정정: CI 서명 게이트는 여전히 막혀 있지만, updater feed에는 1.3.2~1.3.7이 로컬 release:updater 경로로 게시되어 있다(GAP-REL-06). |
| GAP-REL-06 | Release | 서명 인증서가 없어 stable(latest) 채널에 무서명 설치본을 게시했다. electron-updater는 app-update.yml에 publisherName이 없으면 서명 검증을 건너뛰므로 설치는 동작하지만, SmartScreen 평판은 버전마다 0부터 시작한다. |
scripts/ci/publish-updater-release.mjs, release/update-policy.json, .forgejo/workflows/release.yml |
[!] 2026-09-18: 1.3.2를 --ack-unsigned(명시적 승인 플래그)로 게시. 2026-09-19: 1.3.7도 같은 경로로 게시(npm run release:updater -- --ack-unsigned, 설치본 90.6MiB, latest.yml=1.3.7, 설치본 sha512가 피드 메타데이터와 일치). 인증서 확보 시 더 높은 버전으로 서명 게시하여 대체하고, 이 예외를 제거한다. |
| GAP-REL-07 | Release | 패키징된 better-sqlite3가 호스트 Node ABI여서 1.3.2 설치본이 시작 즉시 죽었다(NODE_MODULE_VERSION 131 vs 130). 원인: 로컬 npm install이 네이티브 모듈을 Node용으로 재빌드했고 패키징이 재빌드를 건너었다. |
scripts/ci/verify-native-abi.mjs, scripts/ci/fix-native-abi.mjs, .gitlab-ci.yml/.forgejo/.github 패키징 단계 |
[x] 2026-09-18: 패키징 후 Electron ABI를 검증하고, 검증된 트리에서만 설치본을 생성(--prepackaged)한다. 1.3.3은 설치본에서 추출한 바이너리로 재검증 GREEN. |
| GAP-ADS-01 | Ads | 9 of 10 desktop ad adapters still extend UnavailableAdAdapter (provider_not_integrated). |
apps/desktop/src/main/services/ads/* |
[~] 2026-09-13: DirectHouseSponsorAdapter is now a real configurable REST adapter (bid/impression/click/reward via endpointUrl; fail-closed when unconfigured; 22 unit tests GREEN). Remaining 9 need official SDKs/authenticated endpoints. |
| GAP-ADS-02 | Ads | Desktop mediation reward accounting is not wired to license quota (claimReward still returns no tokens). |
AdMediationEngine.ts, AppLayout.tsx |
Wire verified reportRewardCompletion to LicenseService quota after the direct sponsor endpoint exists. |
| GAP-ID-01 | Identity | Supabase, .NET JWT/SQLite, and the desktop offline license each had their own tier/role shape. | @d3ro/core/entitlement, LicenseService, entitlement-context |
[~] 2026-09-13: canonical EntitlementSnapshot + resolveEntitlement added with tests; desktop tier normalization + isPro fixed. Full adoption tracked as GAP-ID-02. |
| GAP-ID-02 | Identity | Web and mobile still hand-roll tier/role normalization instead of consuming the canonical resolver; .NET identity is still a separate store. | apps/web/src/lib, apps/mobile-rn/src/lib/entitlement-context.tsx |
Adopt resolveEntitlement in web/mobile; decide whether to retire the .NET user store or keep explicit mapping. |
| GAP-TEAM-01 | Teams | Team comments / activity feed was not implemented. | server/supabase/migrations/20260913000033_team_activities.sql, web activity-feed.tsx, mobile team-service.ts |
[x] 2026-09-13: team_activities table + create_team_activity RPC + RLS + realtime publication; web + mobile UI. |
| GAP-MEM-01 | Knowledge | Web file upload was deferred and semantic search was mislabeled future. | apps/web/src/components/knowledge/* |
[x] 2026-09-13: .txt/.md picker, newline-aware chunking, embed-chunks on submit, search-knowledge confirmed live. |
| GAP-MEM-02 | Dictionary | No dictionary import/export on any surface. | catalog MEM-07 | [x] 2026-09-13: desktop dictionary:import/export (JSON/CSV), web serialize/parse + download/upload, mobile via data-portability; 8 new unit tests. |
| GAP-MTG-01 | Meetings | Audio seek ↔ transcript timestamp sync missing. | SSOT H-011 | Store segment timestamps; wire player seek. |
| GAP-MTG-02 | Meetings | Speaker diarization only partially done on desktop; absent web/mobile. | docs/phases/phase-15.5-speaker-diarization.md, SSOT H-014 |
Finish desktop pyannote path; expose speaker labels cross-surface. |
| GAP-INFRA-01 | Build | apps/mobile-rn is outside npm workspaces, so root typecheck/test/lint skip it. typecheck:mobile/lint:mobile/test:mobile/verify:all root scripts added 2026-09-13 (package.json), but membership/CI integration is still open. |
package.json |
Decide: add mobile to workspaces, or wire verify:all into CI. |
| GAP-MOB-01 | Legacy | apps/mobile Expo skeleton duplicated auth/data code and got version-sync edits. |
SSOT G-002 | [x] 2026-09-13: deleted apps/mobile, removed from sync-version.mjs and package-lock.json; version:check GREEN. |
| GAP-INFRA-02 | CI | GitLab admin NAS deploy job is disabled; admin deploy is manual/GHCR. | .gitlab-ci.yml (admin job comments) |
Re-enable with a protected environment, or document the manual runbook as canonical. |
| GAP-INFRA-03 | Release | Desktop auto-update was single-channel and pointed at GitLab while the public hub was Forgejo; no channel, mandatory-update, major-vs-delta, staging, or kill-switch policy. | apps/desktop/src/main/update-feed.ts, electron-builder.yml, .forgejo/workflows |
[x] 2026-09-13: canonical Forgejo feed + legacy GitLab mirror, release/update-policy.json + runtime policy, publish-forgejo-release.mjs + Forgejo release workflow, 21 policy tests, verifier self-test 13 cases. See docs/deployment/update-system-assessment.md. |
| GAP-INFRA-04 | Quality | Desktop typecheck is a no-op: tsconfig.json is files: [] + references, so tsc --noEmit checks nothing. Real tsc -p tsconfig.node.json --noEmit surfaces many pre-existing errors. |
apps/desktop/package.json, apps/desktop/tsconfig.json |
Switch to tsc -b (or per-project -p) and clear the existing errors in a dedicated workstream; do not treat "typecheck GREEN" as evidence until then. |
| GAP-SHELL-01 | Support | Admin /support is a stub panel; desktop-only SupportModal. No shared ticket contract. |
apps/admin/src/app/(admin)/support/page.tsx |
Define a ticket/diagnostics contract or keep stub and mark N/A in catalog. |
| GAP-PUSH-01 | Push | send-push accepted webpush/apns but marked them push_provider_not_supported. |
_shared/webpush.ts, _shared/apns.ts, send-push/index.ts |
[x] 2026-09-13: VAPID Web Push (RFC 8291) + APNs .p8 transports implemented and routed; 10 new tests incl. an encryption round-trip. Client registration for those providers still pending (see GAP-PUSH-03). |
| GAP-PUSH-02 | Push | Nothing triggered send-push?mode=drain; enqueued notifications never left the outbox. |
server/cloudflare-worker/src/push-drain.ts, wrangler.toml |
[x] 2026-09-13: Cloudflare Cron Trigger (* * * * *) drains the outbox; tests in CI. Requires SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY secret on the worker. |
| GAP-PUSH-03 | Push | Mobile/web clients register only fcm; no service worker subscription or APNs device token. |
apps/mobile-rn/src/features/notifications/*, apps/web |
Add web service worker + pushManager.subscribe (store JSON subscription) and iOS APNs token registration. |
| GAP-PUSH-04 | Push | Android still depends on FCM (google-services.json). |
apps/mobile-rn/android, send-push |
Decide: minimal Firebase project, or UnifiedPush/ntfy. See docs/deployment/push-transport-without-firebase.md. |
| GAP-STT-01 | Local STT | Packaged desktop builds shipped no faster-whisper sidecar: electron-builder.yml had no extraResources entry for it and no pipeline job built it. Local transcription was impossible on any installed build; the app fell back to a system Python without the runtime. |
apps/desktop/electron-builder.yml, .gitlab-ci.yml, apps/desktop/scripts/build-sidecar.mjs |
[x] 2026-09-18: sidecar bundled via extraResources (sidecar-dist/sidecar → resources/sidecar), sidecar:setup/sidecar:build scripts, and scripts/ci/verify-sidecar-bundle.mjs fails packaging when the engine or its Silero VAD data is missing. Verified on the real 242 MB bundle. |
| GAP-STT-02 | Local STT | Dev resolved the sidecar/SoX paths against the Vite output dir (out/main), so the sidecar ran a Python without faster-whisper and SoX recording died with spawn sox ENOENT. |
apps/desktop/src/main/utils/paths.ts, apps/desktop/tests/main/utils/paths.test.ts |
[x] 2026-09-18: app root is discovered by walking up for app markers; packaged mode fails loudly instead of silently falling back. Unit tests pin both branches. |
| GAP-STT-03 | Local engines | On hosts where localhost resolves only to IPv6, every local engine call (STT sidecar and Ollama) was refused. Audio capture and local LLM appeared dead. |
apps/desktop/src/main/utils/loopback.ts, LocalSTTService, LocalLLMService, RAGService, OnlineLLMService, STTManager |
[x] 2026-09-18: loopback normalization to 127.0.0.1 for all local engine URLs; defaults updated; 9 unit tests. Verified against the live sidecar and Ollama on a host with an IPv6-only localhost. |
| GAP-STT-04 | Local STT | Live partial transcript (CAP-03, voice:partialTranscript) was marked done but had no producer: the channel, popup UI, and preload existed, nothing ever emitted. |
apps/desktop/src/main/services/VoiceModeService.ts, LocalSTTService.transcribePartial, STTManager.transcribePartial |
[x] 2026-09-18: 1.5 s cadence over a 7.5 s trailing window, greedy decode, drained before the final transcription; never inserted. |
| GAP-STT-05 | Local STT | The bundled sidecar lacked faster-whisper's Silero VAD data, so vad_filter=true transcription would have failed at runtime even with the engine bundled. |
apps/desktop/scripts/build-sidecar.mjs, scripts/ci/verify-sidecar-bundle.mjs |
[x] 2026-09-18: --collect-all faster_whisper plus a packaging-time presence check for assets/silero_vad_v6.onnx. |
| GAP-REL-03 | Release | 서명이 없어 설치할 수 있는 경로가 없다(인증서 발급 전 공백). | .forgejo/workflows/portable.yml, scripts/ci/build-portable.mjs, scripts/local/install-d3ro-voice.ps1, bucket/d3ro-voice.json |
[x] 2026-09-18: 서명 없는 portable 채널 구현 — 95MiB 7z 분할 볼륨(688MB → 162MiB) + Scoop 버킷 + 수동 설치 스크립트를 Forgejo에 게시. 실제 설치 스크립트 end-to-end 검증(볼륨 다운로드 → SHA-256 → 결합 → 해제 → 엔진 포함 확인). updater feed는 건드리지 않음. 2026-09-19: 1.3.7이 CI portable run#64에서 portable-latest에 게시됨(7z 단일 볼륨 83.7MB + zip 2부, portable.json=1.3.7) — 즉 portable 경로는 CI만으로 동작하고, 실패한 것은 마지막 증거 업로드 단계다(GAP-REL-11). |
| GAP-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 100MiB를 넘으면 HTTP 413으로 거부한다. | scripts/ci/publish-updater-release.mjs, docs/deployment/unsigned-distribution.md |
[~] 2026-09-18: 설치본을 90.6MiB로 줄여 업데이트 피드 게시를 복구했다(GAP-STT-07). 휴대용/Scoop 채널은 여전히 95MiB 분할이 필요하다. |
| GAP-STT-07 | Local STT | 진(사이드카)을 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | apps/desktop/src/main/services/RuntimeProvisioner.ts, apps/desktop/electron-builder.yml |
[x] 2026-09-18: 설치본에서 엔진/ffmpeg를 제거하고 처음 필요할 때 runtime-latest에서 내려받는다(부품별 + 결합본 SHA-256 검증). 설치본 189MB → 90.6MiB, 런타임 1회 116MiB(엔진 94.4 + ffmpeg 21.7). 실제 feed로 통합 검증 완료. |
| GAP-STT-06 | Local STT | Decode settings were untuned: previous-text conditioning let repeated hallucinations compound, and no VAD parameters meant slow, uneven segments. | apps/desktop/sidecar/main.py |
[x] 2026-09-18: condition_on_previous_text=false, bounded low-temperature fallback, no_speech/compression_ratio/log_prob thresholds, 300 ms silence trimming. Same transcript, ~5x faster on the reference machine (7.7 s audio: 1609 ms → 303 ms). |
| GAP-KEY-01 | Key bindings | 키바인딩 전면 개편(CAP-16)이 실앱 구동으로 검증되지 않은 상태였다. uiohook 전역 후킹, 더블프레스 타이밍, 마우스 버튼 수신을 실행 중인 Electron 에서 확인한 적이 없었고, 증거가 계약 수준(packages/core 117개 통과, 키바인딩 파일 타입 에러 0건)뿐이었다. 에이전트는 데스크톱 GUI 를 띄울 수 없다(AGENTS.md §3). |
apps/desktop/src/main/services/KeyBindingService.ts, packages/core/__tests__/keybinding.test.ts, %APPDATA%/d3ro-voice/logs/main.log, %APPDATA%/d3ro-voice/d3ro-voice-config.json |
[x] 2026-09-21: 사용자가 Windows 에서 앱을 띄워 검증 완료. 로그(12:53–13:06)에 Migrated 4 legacy shortcut(s) to keyBindings → Loaded 6 key binding(s) from config for 6 action(s) → uiohook started, global keyboard/mouse hook active → key-bindings initialized → Key binding events connected 가 순서대로 남았고 부팅 에러 0건, STT 모델 로딩까지 정상. 키보드·마우스(MB4/MB5) 바인딩을 UI 로 실제 조작해 동작을 확인했고, 같은 세션의 Loaded 7 key binding(s) … for 6 action(s) 가 다중 바인딩이 실동작함을 보인다. 설정 파일 되읽기로 마이그레이션 결과를 확인 — 사용자 커스텀 값이 그대로 보존됐고(dictation code:49,alt / hands-free code:49,alt+shift / command code:165,ctrl / caption code:49,ctrl+alt+shift, 팝업 2종은 신규 기본값), 구 *Shortcut 4개와 displayLabel 은 모두 사라졌다. 남은 것: KeyBindingService 자체의 유닛 테스트는 여전히 없다(실행 검증이 유닛 테스트를 대체하지 않는다). macOS/Linux 는 GAP-KEY-02 로 계속 열려 있다. |
| GAP-KEY-02 | Key bindings | 마우스 버튼 지원이 Windows 기준으로만 설계·확인됐다. KeyBindingService 에는 마우스 관련 플랫폼 분기가 없고(process.platform 은 meta 수정자 라벨 표기에만 쓰인다), macOS/Linux 에서 uiohook 이 보고하는 X1/X2 버튼 번호와 OS 기본 "뒤로/앞으로" 동작과의 간섭은 확인하지 않았다. 마우스 이벤트는 suppress 가 불가능하므로 원래 동작이 항상 함께 실행된다. |
KeyBindingService.ts:235(readMouseButton), :301(meta 라벨 분기), packages/core/src/keybinding.ts:561-612(마우스 카탈로그 5종). 2026-09-21 실앱 검증(GAP-KEY-01)은 Windows 에서만 이뤄졌고 거기서는 MB4/MB5 가 정상 동작했다. |
macOS/Linux 에서 MB2~MB5 수신 여부와 버튼 번호 매핑을 확인하고, 다르면 카탈로그를 플랫폼별로 분기한다. |
| GAP-KEY-03 | Key bindings | command 액션에 전용 핸들러가 없다. 이번에 처음으로 설정 UI 에 노출됐지만, 트리거되면 dictation 파이프라인으로 fallback 하며 KEYBINDING_ACTIONS 의 holdMode:false 대신 dictation 과 같은 hold-to-talk 로 강제된다. 개편 이전부터 같은 동작이었고 이번 작업은 그 사실을 코드에 명시화만 했다(기능 변화 없음). |
apps/desktop/src/main/services/VoiceModeService.ts:1071(_resolveHoldMode), packages/core/src/keybinding.ts:740(액션 정의) |
command 전용 동작을 정의하고 _resolveHoldMode 의 예외를 제거하거나, 액션을 카탈로그에서 뺀다. |
| GAP-QA-02 | Quality | 캡션 테스트 2건이 개발 머신에 사이드카 venv 가 있는지에 따라 결과가 갈린다. LocalSTTService.initialize()(:239) → _ensureSidecarRunning()(:583) → _spawnSidecar()(:650) → _waitForHealth()(:794) 경로에서 venv 가 존재하면 실제 Python 프로세스를 띄우고 health 폴링이 vitest 기본 타임아웃 10초를 넘긴다. venv 가 없으면 getSidecarCommand()(apps/desktop/src/main/utils/paths.ts:174)가 즉시 throw 해서 같은 테스트가 빠르게 통과한다. 테스트가 로컬 환경을 격리하지 못한 것이 결함이다. |
tests/red/ipc-surfaces.usecase.test.ts(캡션 시작 실패는 success:false 로 나온다), tests/red/silent-errors.usecase.test.ts:48. 키바인딩 개편의 회귀가 아니다 — 2026-09-21 에 HEAD(0ca9e24) 무수정 코드를 같은 환경(venv 연결)에서 돌려 동일하게 재현했다. 같은 날 같은 머신에서도 실행 방식에 따라 결과가 갈렸다: 전체 실행은 3 failed / 1311 passed (1314)(rag.usecase + silent-errors 캡션 + paths.test)이고 ipc-surfaces 캡션 케이스는 통과했는데, 그 파일만 단독 실행하면 같은 케이스가 10초 타임아웃으로 실패한다. 테스트 총수 1314 는 어느 실행에서나 같고, 새로 깨진 테스트는 0건이다. |
사이드카 기동을 테스트 경계에서 주입·모킹해 환경 의존을 끊는다. 함께 실패하는 rag.usecase(임베딩 서버 부재)도 같은 성격이다. tests/main/utils/paths.test.ts:78 은 성격이 다르다 — 기대 정규식이 사이드카를 찾을 수 없습니다 인데 실제 메시지는 로컬 음성 엔진이 아직 설치되지 않았습니다… 로 바뀌어 테스트가 문구를 따라가지 못한 것이다. |
| GAP-I18N-01 | i18n | 로케일별 키 수가 크게 어긋난다. 2026-09-21 실측: ko 1716 / en 1709 / 나머지 10개 로케일 각 327. keybinding.* 55개는 12개 로케일 전부에 동일하게 들어갔지만, 그 밖 약 1,380개 키가 비영어 로케일에 없어 폴백 체인(locale → en → ko)으로 표시된다. 키바인딩 작업 이전부터 있던 부채이며 그 작업 범위 밖이었다. |
packages/i18n/src/locales/*.json, 카탈로그 SHELL-03. 구체 사례 (2026-09-21 실측): popup.error.default 가 en.json·ko.json 에만 있고 나머지 10개 로케일에 없다. 소비처는 WindowManager.ts:59(팝업 문자열 주입, 선재)와 CommandsPage.tsx:198·:201(LLM 수정으로 추가된 파이프라인 벤치 오류 표시) 두 곳이며, 비영어 사용자에게는 오류 메시지가 영어로 폴백된다. 새 갭이 아니라 이 행이 세는 약 1,380개 중 하나다 — 별도 행을 열지 마라. |
로케일 간 키 diff 를 내는 커버리지 게이트를 만들어 회귀를 막고, 누락 키를 채운다. |
| GAP-I18N-02 | i18n | 렌더러가 ko.json 에 없는 license.* 키를 쓴다. TranslationKey 가 ko.json 에서 파생되므로 누락은 타입 에러로 드러난다. 타입 에러로만 끝나지 않는다 — 폴백 체인이 locale → en → ko → 키 문자열 이므로 마스터 로케일에도 없으면 license.team 같은 키가 화면에 그대로 노출된다. 2026-09-21 실측: license.feature.premium_llm·license.team·license.enterprise 가 없고 이로 인한 TS2345 가 4건이다. HEAD 에서도 없던 키이므로 선재 결함이며 키바인딩 작업과 무관하다. |
apps/desktop/src/renderer/components/UpgradePromptModal.tsx:47·:192, apps/desktop/src/renderer/pages/DashboardPage.tsx:481·:529, packages/i18n/src/locales/ko.json |
세 키를 ko.json 에 추가하고 12개 로케일에 반영한다. 같은 타입체크에 잡히는 LicenseTab.tsx(6건)·LicenseModal.tsx(2건)는 원인이 다르다 — TFunction 을 (k: string) => string 에 넘기는 TS2322 4건과 currentTier 미정의 TS2304 2건으로, 후자는 컴파일이 깨지는 별개 결함이다(GAP-INFRA-04 범위). |
| GAP-LLM-01 | LLM | 번역 대상 언어를 사용자가 고를 수 없다. 항상 English 고정이다. AppConfig 에 대상 언어 키가 없고, 기존 두 키 모두 대용할 수 없다 — language 는 UI 로케일이라 'ko' 같은 코드가 프롬프트에 그대로 들어가 문장이 깨지고, sttLanguage 는 입력(원문) 언어라 그 값으로 번역하면 원문이 그대로 나온다. 2026-09-21 LLM 수정(9c2b4d4)은 대상 언어가 호출 프레임 두 단계 밖의 기본값에 의존하던 것을 명시 인자로 바로잡았을 뿐, 선택지를 만들지는 않았다(설정 키 + 설정 UI + i18n 이 필요해 patch 범위 밖으로 뒀다). |
apps/desktop/src/main/services/llm-prompts.ts:37(DEFAULT_TARGET_LANGUAGE)·:50(resolveTargetLanguage), packages/core/src/types.ts(AppConfig 에 키 없음), 내장 프리셋 CustomInstructionService.ts:26 |
AppConfig 에 대상 언어 키를 추가하고, 설정 UI(LLM 탭)에 노출하고, resolveTargetLanguage() 가 설정을 읽게 한다. 언어 목록과 라벨은 i18n 키가 필요하다. |
| GAP-LLM-02 | LLM | 2026-09-21 LLM 지시문 수정(9c2b4d4)이 실앱 구동으로 검증되지 않았다. 유닛 테스트는 통과하지만(llm-prompts.test.ts 21, llm-handlers.test.ts 7, VoiceModeService.test.ts 22, ChainService.test.ts 5 — 수정 4건을 각각 되돌려 실제로 실패하는 것까지 확인), 실행 중인 Electron 에서 실제 지시문을 돌려 결과가 삽입되는 것을 본 적이 없다. 에이전트는 데스크톱 GUI 를 띄울 수 없다(AGENTS.md §3). 게다가 이 수정과 직접 관련된 usecase 테스트 4개가 실행조차 되지 않았다 — tests/red/{instruction,chain,voice,config}.usecase.test.ts 가 better-sqlite3 ABI 불일치로 DB 생성 단계에서 먼저 죽는다(GAP-INFRA-06). 즉 그 범위는 통과도 실패도 아닌 미검증이다. 영향 받는 카탈로그 행: AI-04, AI-05, AI-06, AI-07(전부 데스크톱 [~]). |
apps/desktop/src/main/services/llm-prompts.ts, VoiceModeService.ts:779·:827-870, ChainService.ts:196, src/main/ipc/llm-handlers.ts:96, src/renderer/pages/CommandsPage.tsx:180-205 |
사용자가 run-desktop.bat 로 앱을 띄워 (1) 명령 페이지에서 내장 프리셋(번역/요약/전문 리라이트/코드 설명/자유 프롬프트)을 활성화한 뒤 받아쓰기, (2) 명령 팝업에서 선택 후 받아쓰기, (3) 음성 키워드로 명령 호출, (4) 체인 실행, (5) 명령 페이지 파이프라인 벤치를 각각 돌려 지시문 문구가 아니라 처리 결과가 삽입되는지 확인한다. GAP-INFRA-06 의 ABI 전환 스크립트가 생기면 usecase 4종을 함께 돌린다. |
| GAP-INFRA-06 | Dev env | better-sqlite3 네이티브 ABI 가 앱 실행과 로컬 테스트에서 서로 다른 값을 요구한다. Electron 33 은 ABI 130, 호스트 Node 23 은 ABI 131 이라 한쪽에 맞추면 다른 쪽이 깨진다. 2026-09-21 실측: electron-rebuild -f -w better-sqlite3 직후 vitest 가 366 failed / 948 passed 로 무너졌고, 리빌드 전에는 1311 passed 였다. 같은 날 확인한 현재 워크스페이스는 Node ABI 쪽(호스트 node -e "require('better-sqlite3')" 성공)이라 테스트는 돌고 앱 실행에는 재리빌드가 필요하다. 배포 차단 이슈가 아니다 — node_modules/ 는 gitignore(.gitignore:1)이고 패키징 경로는 scripts/ci/verify-native-abi.mjs 가 이미 막는다(GAP-REL-07 [x]). 순수하게 로컬 개발 환경 전환 비용 문제다. 다만 전환 비용으로 끝나지 않는다 — 검증을 가린다. Electron ABI 쪽으로 리빌드된 상태에서는 tests/red/*.usecase.test.ts 가 DB 생성 단계에서 먼저 죽어 그 안의 케이스가 통과도 실패도 하지 않는다. 2026-09-21 LLM 지시문 수정(9c2b4d4)이 그 사례다: 전체 실행이 366 failed / 994 passed (1360) 였고 실패 366건 중 365건이 이 ABI 로 죽은 usecase 파일들인데, 하필 instruction·chain·voice·config usecase 가 그 수정의 직접 영향 범위였다(GAP-LLM-02). 참고로 같은 날 clean tree 베이스라인은 366 failed / 948 passed (1314) 로 실패 수가 동일해 신규 실패는 0건이다. |
scripts/ci/verify-native-abi.mjs, scripts/ci/fix-native-abi.mjs, package.json(현재 리빌드용 스크립트 없음), apps/desktop/tests/red/*.usecase.test.ts |
두 ABI 를 오가는 npm 스크립트를 둔다(예: rebuild:app = Electron ABI, rebuild:test = Node ABI). 지금은 전환 방법이 문서화도 스크립트화도 되어 있지 않아 매번 수동으로 알아내야 한다. |
| GAP-STT-08 | Local STT | 1.3.5 설치본에서 엔진 설치가 "런타임 아카이브 해시 불일치 (sidecar)"로 항상 실패했다. 부품 검증은 메모리 스트림에서 센 값으로, 결합 검증은 디스크 파일에서 계산해 기준이 서로 달랐다. 디스크 쓰기가 잘려도 부품 검사를 통과하고 결합 단계에서만 터지므로 원인 파악도 불가능했다. 재시도가 없어 전송이 한 번 끊기면 곧바로 설치 실패였다. | apps/desktop/src/main/services/RuntimeProvisioner.ts |
[x] 2026-09-18: 부품 크기·해시를 디스크 파일 기준으로 통일하고, 결합본은 크기를 먼저 검사한 뒤 해시를 본다(오류 메시지에 실제/기대값 포함). 부품 다운로드는 실패 시 해당 파일을 지우고 최대 3회 재시도한다. 서버 아티팩트는 무결함을 확인했고(부품 2개 해시 일치, 결합본 e203aa53… = 인덱스 기대값), 실제 feed로 설치를 재현해 18초 만에 성공. 1.3.6으로 게시 완료 — latest.yml이 1.3.6/90.6MiB를 서빙하고 설치본 sha512가 피드 메타데이터와 일치. 설치본 asar에 수정 코드가 포함되고 구버전 archiveHash 경로는 제거됨을 확인. |
| GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic <script src="./script.js">를 참조해 Vite가 처리하지 않았고, dev에서는 로드되지만 설치본에는 파일이 없었다. 그래서 녹음 오버레이가 0:00에서 멈추고 웨이브 바가 뜨지 않았으며 실시간 자막이 렌더되지 않았다. 로드 전 webContents.send가 조용히 버려지는 문제와 hide() 이후 재표시의 z-order/repaint 유실도 함께 있었다. | apps/desktop/src/renderer/popups/*/index.html, apps/desktop/src/main/windows/WindowManager.ts, scripts/ci/verify-desktop-renderer-bundles.mjs | [x] 2026-09-19: 팝업 5종을 type="module"로 전환해 Vite가 해시된 번들로 방출하도록 고쳤고, 빌드 HTML이 참조하는 모든 로컬 asset이 디스크에 있는지 검사하는 verify-desktop-renderer-bundles.mjs(+ self-test)를 .forgejo/.github 패키징 파이프라인에 연결했다. WindowManager는 렌더러 준비 전 IPC를 did-finish-load까지 보관하고, 팝업을 표시할 때마다 topmost 재선언 + 강제 repaint를 수행하며, 팝업 렌더러 콘솔/로드 실패를 main 로그로 승격한다. |
2. Mobile checklist roll-up (from MOBILE_APP_COMPLETION_SSOT.md §4)
These are the mobile SSOT rows still [ ] / [~]. Do not duplicate the full text here; open the SSOT for detail.
| Group | Open rows | Theme |
|---|---|---|
| G (governance) | G-002, G-006, G-007, G-008, G-009, G-010, G-011 | Type/error/identity consolidation, legacy bundle separation, credential rotation |
| A (auth/account) | A-001..A-013, A-018; A-014..A-017 [~] |
Session restore, signup, deep links, OAuth, profile, provider E2E |
| O (onboarding/a11y) | O-001..O-012 | Onboarding branches, permissions, tutorial, a11y, full ko/en |
| D (data/sync/offline) | D-001..D-014 | Schema/RLS, devices, cross-surface read/write, offline queue, conflict policy |
| R (record/transcribe) | R-001..R-009, R-011..R-015, R-017..R-020; R-016 [x] |
Permissions, recorder, FGS, upload queue, job state, sharing |
| H (history/meetings) | H-001..H-005, H-007..H-015; H-006 [x] |
Pagination, filters, detail, meeting timeline, docs, diarization, cross-app |
| F (feature parity) | F-001..F-010, F-013, F-014; F-011/F-012 [x] |
Dashboard, dictionary, commands, actions, chat, conversation, knowledge |
| T (teams/admin/notify) | T-001..T-005, T-009..T-012; T-006..T-008, T-013 [x] |
Team CRUD/roles, push delivery/deep links, notification settings |
| M (monetization) | M-025, M-027; M-013 [~] |
Play Billing license test, Payple webhook signature |
| Q (quality/release) | Q-012..Q-014, Q-019..Q-022, Q-026; Q-010/Q-011/Q-023/Q-027/Q-028 [~] |
Emulator scripts, OAuth E2E, billing E2E, visual/a11y gates, env unification, production AAB |
3. External blockers (EXT) — require action outside the repo
| ID | Blocker | What is needed | Where tracked |
|---|---|---|---|
| EXT-FIREBASE-01 | No production Firebase project | Android FCM only; web/iOS can avoid Firebase (see push transport doc). Create project + Android app + Play fingerprint + FCM, or adopt UnifiedPush for Android. | SSOT EXT-011, docs/deployment/push-transport-without-firebase.md |
| EXT-ADMOB-01 | AdMob 검토 필요 / 광고 게재 제한 / store not linked / payment profile incomplete |
Complete console review + store link + payment profile. Playwright tooling ready: scripts/admob-login.mjs (one interactive login on a visible desktop) then scripts/admob-automate.mjs --apply creates/verifies units and reports store link. Neither persisted profile is signed in yet. |
SSOT §0, EXT-006 |
| EXT-PLAY-01 | Play Billing license tester + test payment method | Configure license testers | SSOT EXT-004 |
| EXT-PLAY-02 | Play product/offer/base-plan + tracks | Create Pro/Pro+ products and tracks | SSOT EXT-003 |
| EXT-PLAY-03 | Production AAB + CI secret injection + recovery backups | Inject Firebase/AdMob/signing/evidence CI secrets | SSOT EXT-008 |
| EXT-PLAY-04 | Closed test 0/12 members, 14 days; production access disabled | Run closed test, request production | SSOT EXT-010 |
| EXT-OAUTH-01 | GitHub/Apple provider secrets; real mobile Google consent→callback | Configure providers, verify consent | SSOT EXT-001 |
| EXT-APPSIGN-01 | Live App Links still old certificate | Deploy updated assetlinks.json, re-verify live |
SSOT App Links row |
| EXT-PHYS-01 | Physical Fold6 install/OAuth/purchase evidence | User runs the artifact on device | SSOT EXT-009 |
| EXT-PAY-01 | Payple live history + webhook signature verification | Provider contract + signature scheme | SSOT M-027 |
| EXT-STRIPE-01 | Production Stripe/Payple cross-verification | Live payment E2E | SSOT M-013 |
| EXT-STT-01 | Production provider keys (Groq/OpenAI/Deepgram/Gemini) | Inject provider secrets | apps/api-server/Program.cs env docs |
4. Documentation & drift watch
| ID | Item | Note |
|---|---|---|
| DOC-01 | CLAUDE.md describes only Phase 1-15.5 desktop; does not mention web/mobile/admin/api-server. |
This map supersedes it for IA; consider trimming CLAUDE.md to rules + map pointer. |
| DOC-02 | docs/design/* reflects an older desktop-only architecture and contains known naming drift (06-gap-analysis.md). |
Treat this map + code as current; design docs are historical. |
| DOC-03 | memory/project_status.md is a chronological log, not current state. |
Use docs/map/* for current state. |
| DOC-04 | docs/v2/00-v2-master-plan.md targets Expo for mobile. |
Actual mobile is RN CLI (apps/mobile-rn); the Expo app was removed 2026-09-13. v2 plan is historical. |
| DOC-05 | This map itself must be regenerated after large refactors. | See update protocol. |
5. Quick "is X done?" lookup
- Desktop local dictation / LLM / history / meetings / RAG / conversation: local
dictation works in dev and in packaged builds as of
1.3.0(engine bundled, paths fixed, IPv4 loopback). Ads: one real adapter, rest stubs. - Desktop LLM post-processing: split the question. The plain actions
(
refine/summarize/grammar/expand, Settings →defaultLLMAction) always worked and are now regression-tested. The custom-instruction path — built-in presets, user commands, voice keyword commands, chains — never worked in any shipped release (v0.1.0-alpha..v1.4.0): it inserted the instruction's own wording instead of the result. Fixed 2026-09-21 in9c2b4d4with unit tests, but not yet verified in a running app, and the four related usecase test files could not execute (GAP-LLM-02, GAP-INFRA-06). Translate is still hardcoded to English (GAP-LLM-01). - Desktop key bindings (CAP-16): rewritten onto one SSOT with multiple bindings per
action and mouse-button support; verified on Windows by a manual run on 2026-09-21
(legacy migration, 6 actions loaded, keyboard + mouse hook live, multi-binding exercised
— GAP-KEY-01
[x]). macOS/Linux mouse behavior is still unconfirmed (GAP-KEY-02) and the service has no unit test of its own. - Web console: yes, feature-complete for server-shared data; knowledge upload/search and team feed implemented.
- Mobile: code complete for most flows and tested locally; blocked mainly by external store/console gates, plus a11y and some E2E depth.
- Backend: fail-closed AI proxies, RLS, billing, ads SSV implemented; push transports (FCM + webpush + APNs) and cron drain implemented.
- Admin: complete with deliberate "unavailable" states; support page is a stub.
6. Immediate TODO — push transports, drain, AdMob (2026-09-13)
Actionable checklist for the work started this session. Fields to fill are blank in
.env (git-ignored) and mirrored in .env.example.
Push — server (code done, config pending)
- Set Supabase Edge secrets for the transports you deploy:
WEBPUSH_VAPID_PUBLIC_KEY,WEBPUSH_VAPID_PRIVATE_KEY,WEBPUSH_SUBJECT(web);APNS_KEY_ID,APNS_TEAM_ID,APNS_PRIVATE_KEY,APNS_TOPIC,APNS_ENVIRONMENT(iOS);FCM_SERVICE_ACCOUNT_JSON,FCM_PROJECT_ID(Android). - Deploy the updated
send-push(supabase functions deploy send-push). - Generate a VAPID keypair (P-256) and store the private/public pair; subject must be
mailto:orhttps:. - Create the APNs
.p8key in the Apple Developer portal and record Key ID + Team ID.
Push — Cloudflare cron drain
- Set worker vars:
SUPABASE_URL(wrangler secret/[vars]) and batch limit. - Set worker secret:
wrangler secret put SUPABASE_SERVICE_ROLE_KEY. wrangler deployfromserver/cloudflare-workerand confirm thescheduledruns (Cloudflare dashboard → Cron Triggers).- Verify end-to-end: enqueue a test event (e.g. team invite) → drain → delivery row
delivered.
Push — clients
- Web: add a service worker +
pushManager.subscribe, store the JSON subscription as the registration id, registerprovider: 'webpush'. (GAP-PUSH-03) - iOS: register the APNs device token with
provider: 'apns'. (GAP-PUSH-03) - Android: decide FCM vs UnifiedPush/ntfy. (GAP-PUSH-04)
AdMob (Playwright tooling ready)
- Run
run-admob-login.bat(ornode scripts/admob-login.mjs) once on a visible desktop to persist the Google session. - Run
node scripts/admob-automate.mjs --applyto create/verify the banner + rewarded units and report the Play-store link. - Complete AdMob console gates externally: review, serving limits, store link, payment profile. (EXT-ADMOB-01)
Docs
- Keep
docs/deployment/push-transport-without-firebase.mdand this file in sync when transports or clients change.
7. 알려진 설계 제약 (수용됨 — 결함 아님)
여기 있는 항목은 고쳐야 할 갭이 아니라 대안을 검토한 뒤 의도적으로 유지하기로 한 절충이다. §1 에 갭으로 재등록하지 마라.
| ID | 제약 | 왜 이대로 두는가 | 완화 장치 |
|---|---|---|---|
| CONSTRAINT-I18N-01 | packages/core/src/keybinding.ts 는 i18n 키를 평범한 string 으로 노출한다. 렌더러가 asTranslationKey()(apps/desktop/src/renderer/components/keybinding/translation-key.ts:7)로 경계에서 캐스팅하므로, 존재하지 않는 키를 넘겨도 컴파일러가 잡지 못한다. |
core 가 로케일 패키지에 의존하지 않게 하려는 의도적 설계다. 검토한 대안 둘 다 성립하지 않는다 — (A) 키 필드를 리터럴 유니온으로 좁히는 방식은 KEY_CATALOG 가 letterEntries() 같은 함수 생성부를 포함해 불가능하고, (B) core 가 @d3ro/i18n 의 타입 가드를 쓰는 방식은 의존 방향을 core → i18n 으로 역전시켜 03-shared-packages.md §6 의 전제를 깬다. 2026-09-21 결정: 현행 유지. |
packages/core/__tests__/keybinding-i18n.test.ts (14 케이스). core 가 참조하는 키가 12개 로케일 전부에 있는지, 값이 빈 문자열이 아닌지, core 가 렌더러 전용 keybinding.ui.* 를 참조하지 않는지 검사한다. 거부 사유 키는 하드코딩 목록이 아니라 실제 validateBinding 경로를 태워 수집하므로 새 사유가 생기면 자동으로 커버된다. |