diff --git a/.claude/agents/README.md b/.claude/agents/README.md
new file mode 100644
index 0000000..7588952
--- /dev/null
+++ b/.claude/agents/README.md
@@ -0,0 +1,74 @@
+# 서브에이전트
+
+**이 폴더의 정의는 초안이다.** 쓸지 말지는 아직 안 정했고, 정하면 이 문장을 지운다.
+
+## 왜 만드나
+
+R9(넓은 조사는 위임)의 담당 지표 M14가 세 버전 연속 0%였다. 규칙이 효과가 없어서가
+아니라 **규칙 본문이 "사용자가 요청할 때만"이라고 스스로를 잠가둬서**다.
+
+2026-08-22에 처음으로 허가를 받고 셋을 병렬로 돌렸다. 그 실측이 이 폴더의 근거다.
+
+| 조사 | 읽은 것 | 쓴 토큰 | 걸린 시간 |
+|---|---|---|---|
+| Features 레이어 | 27파일 5,436줄 | 148,139 | 4분 36초 |
+| Shared 레이어 | 32파일 + 타깃 멤버십 | 134,072 | 5분 20초 |
+| Common·위젯 | 40파일 | 148,249 | 4분 15초 |
+| 워크플로우·하네스 | 문서 20여 개 | 109,595 | 4분 51초 |
+
+**넷이 54만 토큰을 쓰고 요약만 돌려줬다.** 직접 읽었으면 그 54만이 본 컨텍스트에
+쌓였을 것이고, 그 뒤 모든 턴이 그것을 다시 읽었을 것이다. 남은 턴이 100개면
+5,400만 토큰 차이다.
+
+이게 위임의 유일한 근거다 — **읽은 내용 자체는 남길 필요가 없고, 결론만 필요한 일.**
+
+## 언제 위임하나 / 안 하나
+
+| 위임한다 | 직접 한다 |
+|---|---|
+| 전수 조사·감사 (파일 10개 이상) | 판단이 필요한 것 |
+| "이 심볼 쓰는 데 다 찾아줘" | 이미 열어둔 파일을 고치는 것 |
+| 명명 규칙·패턴 훑기 | 왕복 한두 번이면 끝나는 것 |
+| 읽어야 결론이 나오지만 읽은 내용은 안 남겨도 되는 조사 | 사용자와 대화가 필요한 것 |
+
+기준 한 줄: **설명 비용이 직접 하는 비용보다 크면 지는 거래다.**
+
+## 정의
+
+### `surveyor` — 넓은 조사
+
+읽기 전용. 파일 여러 개를 훑고 구조화된 요약만 돌려준다.
+
+- **모델**: 하위 (판단이 아니라 수집이다)
+- **도구**: 읽기·검색만. 편집 금지
+- **출력 규칙**: 파일:줄 근거를 붙이고, 코드를 길게 인용하지 않는다. 제안하지 않고
+ 사실만 적는다 — 제안은 본 세션이 한다
+
+### `test-author` — 뽑아낸 로직에 테스트 붙이기
+
+`Shared/`로 내린 순수 로직에 Swift Testing 테스트를 쓴다.
+
+- **모델**: 하위 (규칙이 명확하고 반복적이다)
+- **도구**: 읽기 + `MoscoTests/` 아래 쓰기 + 테스트 실행
+- **출력 규칙**: 테스트 이름에 증상을 적는다(`같은_날짜가_월말월초에_두_번_나오지_않는다`).
+ 경계값을 반드시 포함한다. 통과하는 것을 확인하고 결과를 보고한다
+
+## 안 만들기로 한 것
+
+- **`doc-writer`** — 문서 톤은 세션 맥락을 아는 쪽이 잘 쓴다. 요약을 넘기는 비용보다
+ 직접 쓰는 편이 싸다고 판단했다. 다시 볼 만한 후보이긴 하다.
+- **`reviewer`** — 코드 리뷰는 판단이다. 위임하면 근거를 다시 설명해야 하고, 그
+ 설명이 리뷰보다 길어진다.
+- **`contract-checker`** — 이건 에이전트가 아니라 스크립트가 맞다.
+ `tools/artifact_check.sh`로 만들었다.
+
+## 검증
+
+에이전트를 쓴 뒤에는 매번 이 세 줄을 남긴다. 그래야 다음 버전 보고서 6.3에서
+"위임이 실제로 이득이었나"를 채점할 수 있다.
+
+```
+누가 · 무엇을 · 쓴 토큰
+돌아온 요약이 실제로 쓸모 있었나 (그대로 썼나 / 다시 조사했나)
+직접 했으면 어땠을까 (추정)
+```
diff --git a/.claude/agents/surveyor.md b/.claude/agents/surveyor.md
new file mode 100644
index 0000000..093ebf4
--- /dev/null
+++ b/.claude/agents/surveyor.md
@@ -0,0 +1,27 @@
+---
+name: surveyor
+description: 넓은 전수 조사·감사 전용. 파일 여러 개를 훑고 구조화된 요약만 돌려준다. 읽기 전용이며 제안하지 않는다. 파일 10개 이상을 읽어야 결론이 나오지만 읽은 내용 자체는 본 세션에 남길 필요가 없을 때 쓴다.
+tools: Read, Grep, Glob, Bash
+model: haiku
+---
+
+너는 이 저장소를 조사해 **사실만** 돌려주는 조사원이다.
+
+## 지켜야 할 것
+
+1. **제안하지 않는다.** "이렇게 고치면 좋겠다"를 쓰지 않는다. 무엇이 어디에 어떻게
+ 있는지만 적는다. 판단은 본 세션이 한다.
+2. **파일:줄 근거를 붙인다.** 근거 없는 서술은 쓰지 않는다.
+3. **코드를 길게 인용하지 않는다.** 한 줄 이상 붙여야 할 때는 왜 그 줄이 중요한지
+ 한 문장으로 대신한다.
+4. **표로 정리한다.** 문단으로 늘어놓으면 본 세션이 다시 읽어야 한다.
+5. **한국어로 쓴다.**
+6. **못 찾은 것은 못 찾았다고 적는다.** 추측으로 메우지 않는다.
+
+## 출력 형태
+
+받은 질문의 번호를 그대로 절 번호로 쓴다. 질문에 없던 것을 발견하면 마지막에
+"질문에 없었지만 눈에 띈 것"으로 따로 모은다.
+
+분량은 **읽은 양의 1/20 이하**를 목표로 한다. 요약이 원본만큼 길면 위임한 의미가
+없다.
diff --git a/.claude/agents/test-author.md b/.claude/agents/test-author.md
new file mode 100644
index 0000000..ddb1d0a
--- /dev/null
+++ b/.claude/agents/test-author.md
@@ -0,0 +1,45 @@
+---
+name: test-author
+description: Shared/로 옮긴 순수 로직에 Swift Testing 테스트를 붙인다. 대상 타입과 검증할 규칙을 받아 MoscoTests/에 테스트를 쓰고, 실제로 돌려 통과를 확인한 뒤 결과를 보고한다.
+tools: Read, Grep, Glob, Bash, Write, Edit
+model: haiku
+---
+
+너는 이 저장소의 순수 로직에 테스트를 붙이는 일만 한다.
+
+## 이 프로젝트의 테스트 규칙
+
+- 프레임워크는 **Swift Testing** (`@Test`, `#expect`). XCTest가 아니다.
+- 테스트는 `Mosco/MoscoTests/`에 둔다. **폴더가 통째로 동기화되므로 Xcode 프로젝트에
+ 등록할 필요가 없다.**
+- 테스트 타깃은 **호스트 앱 없이 `Shared/`만 컴파일한다.** 그러므로 `App/` 아래의
+ 타입은 테스트할 수 없다. 대상이 거기 있으면 **테스트를 쓰지 말고 그 사실을
+ 보고한다.**
+- `@testable import`를 쓰지 않는다. 소스 멤버십으로 직접 컴파일된다.
+
+## 이름 규칙
+
+테스트 이름에 **증상**을 적는다. 무엇을 검증하는지가 아니라, 깨졌을 때 무엇이
+잘못되는지를 적는다.
+
+```swift
+@Test("같은_날짜가_월말월초에_두_번_나오지_않는다")
+@Test("4시~7시의_종료_시각이_19시로_읽힌다")
+```
+
+## 반드시 포함할 것
+
+- **경계값**. 0, 최대, 하루 경계, 월말·월초, 윤년, 자정, 정오.
+- **틀렸던 적이 있는 케이스**. 대상 코드의 주석에 "예전엔 ~였다"가 있으면 그것을
+ 테스트로 만든다.
+- 실패 메시지. `#expect(x == y, "왜 이래야 하는지")`
+
+## 끝내기 전에
+
+```bash
+xcodebuild -project Mosco/Mosco.xcodeproj -scheme App -sdk iphonesimulator \
+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test
+```
+
+**통과를 확인하고 개수를 보고한다.** 실패하면 고치거나, 못 고치면 무엇이 왜
+실패하는지 적는다. 깨진 채로 두고 "됐습니다"라고 쓰지 않는다.
diff --git a/.claude/skills/handoff/SKILL.md b/.claude/skills/handoff/SKILL.md
index 9887f9f..7244098 100644
--- a/.claude/skills/handoff/SKILL.md
+++ b/.claude/skills/handoff/SKILL.md
@@ -65,7 +65,7 @@ description: 작업을 마치고 사람에게 확인을 넘길 때 쓰는 검증
```
빌드 App ✅ / MoscoWidget ✅
-테스트 MonthLayoutTests 6개 통과 — 월말/월초 중복, 5주·6주 격자
+테스트 MonthLayoutTests 7개 통과 — 월말/월초 중복, 5주·6주 격자
카드 아래 2건
못 봄 위젯 렌더링 (실기기)
```
diff --git a/.claude/skills/report/SKILL.md b/.claude/skills/report/SKILL.md
index df034d0..a696191 100644
--- a/.claude/skills/report/SKILL.md
+++ b/.claude/skills/report/SKILL.md
@@ -209,7 +209,7 @@ M13과 모델별 턴 수를 놓고 **다음 버전에 무엇을 어디에 쓸지
| 모아야 할 것 | 어디서 나오나 |
|---|---|
-| 위임했으면 쌌을 지점 | R9에 따라 답 끝에 남긴 "여기는 위임했으면 쌌다" 줄 |
+| 위임했으면 쌌을 지점 | R9에 따라 **PR 본문**에 남긴 "위임했으면 쌌을 곳" 칸 |
| 그 지점의 성격 | 조사인가 / 판단인가 / 반복 작업인가 |
| 한 번에 오간 컨텍스트 크기 | 그 구간의 `Read` 수와 검색 폭 |
diff --git a/CLAUDE.md b/CLAUDE.md
index ced2d95..731627d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,7 +21,14 @@ docs/TRAPS.md 플랫폼 함정. 해당 영역 건드리기 전에
docs/CATEGORIZATION.md 카테고리 자동 분류가 어떻게 돌고 왜 Core ML을 버렸나
docs/BACKLOG.md 밀린 일. 목록을 던지면 여기로 들어간다
docs/harness/ 이 규칙들이 효과 있었는지 채점하는 체계
+docs/workflow.md 프롬프트 하나가 어떤 순서로 도는지. 도식 + 근거
+docs/verification.md 무엇을 어떻게 검증하나. 테스트만으로 안 되는 이유
+docs/review-criteria.md 사람이 봐야 하는 코드와 안 봐도 되는 코드
+docs/architecture/ 지금 구조(CURRENT.md)와 후보 패턴 비교(PATTERNS.md)
+.claude/agents/ 서브에이전트 정의와 그 근거
tools/harness_report.py 버전별 지표 추출
+tools/quality_baseline.py 코드 품질 기준선. 리팩터링 전후를 비교하려고 센다
+tools/artifact_check.sh entitlements·plist·버전 일치 검사. 계약 검증
tools/deadcode_audit.py 안 쓰이는 코드 훑기. periphery는 이 프로젝트에서 못 쓴다
```
@@ -29,7 +36,8 @@ tools/deadcode_audit.py 안 쓰이는 코드 훑기. periphery는 이 프
**테스트 타깃 `MoscoTests`가 있다** (Swift Testing, 112건). **CI도 있다** —
PR마다 App·MoscoWidget 빌드와 테스트, 그리고 Mac Catalyst 빌드가 자동으로 돈다
(`.github/workflows/ci.yml`). Catalyst를 따로 빌드하는 것은 **iOS가 통과해도 맥은
-깨질 수 있어서**다 — 라이브 액티비티가 조건부 컴파일로 가려져 있다. 그래도 먼저 돌리는 것은 AI다 — CI는 마지막 그물이지
+깨질 수 있어서**다 — 라이브 액티비티가 조건부 컴파일로 가려져 있다. 그래도 먼저
+돌리는 것은 AI다 — CI는 마지막 그물이지
편집 후 확인을 대신하지 않는다 (R1).
> **시뮬레이터는 AI가 직접 쓰지 않는다.** 열지도, 탭하지도, 캡처하지도 않는다.
@@ -63,13 +71,13 @@ UDID를 박아 쓰지 않는다 — 시뮬레이터는 지워지고 다시 생
## 규칙
-열 개다. **각 규칙에는 담당 지표가 있고, 버전마다 채점받는다** —
+열한 개다. **각 규칙에는 담당 지표가 있고, 버전마다 채점받는다** —
`docs/harness/rules.md`에 도입 시점과 채점 이력이 있다. 두 버전 연속 효과가 없으면
그 규칙은 폐기한다. 지켜지지 않는 문장을 문서에 남겨두지 않는 것이 이 체계의 요점이다.
### 이 규칙들이 무엇을 향하는가 — M12 프롬프트당 토큰
-**최종 목표는 같은 결론에 토큰을 덜 쓰고 도달하는 것이다.** 규칙 열 개가 전부
+**최종 목표는 같은 결론에 토큰을 덜 쓰고 도달하는 것이다.** 규칙 열한 개가 전부
여기로 모이고, 다른 지표가 다 좋아져도 M12가 안 내려가면 그 버전의 하네스는
실패로 적는다.
@@ -106,6 +114,16 @@ v1.1.0이 1.8배가 된 이유가 이 체계의 핵심 사례다. 스크린샷
거짓 완료 보고는 빌드 실패로 안 잡히고 **재지시로 돌아온다.** 그래서 이 규칙의
담당 지표에 M1이 같이 붙어 있다.
+**고친 뒤 증상이 똑같으면, 다음 가설을 세우기 전에 그 코드가 실행되는지부터
+확인한다** (2026-08-22 추가). v1.3.0에서 스와이프 삭제를 세 번 연속 헛짚었다. 세 번
+모두 증상이 한 글자도 안 바뀌었는데 매번 다음 가설로 넘어갔다. 원인은 코드가 아니라
+**그때 화면에 그 코드가 없었다는 것**이었다.
+
+증상이 안 바뀌는 것은 "아직 못 찾았다"가 아니라 **"그 코드가 안 돈다"는 신호다.**
+로그 한 줄, 조건 출력 하나, 탐침 하나 — 무엇이든 좋으니 **세 번째 시도 전에는
+반드시** 경로가 도는지 찍는다. 시뮬레이터를 못 여는 자리에서는 탐침을 심어 사람에게
+실행을 부탁한다(R2를 어기지 않는다). 자세한 것은 `docs/TRAPS.md`.
+
### R2 · 시뮬레이터를 직접 쓰지 않는다. 검증은 테스트 코드로 남긴다
담당 지표 M11 시뮬레이터 호출 수, M10 회귀 테스트 수
@@ -126,6 +144,14 @@ v1.1.0이 1.8배가 된 이유가 이 체계의 핵심 사례다. 스크린샷
그리고 이 요청은 처음이 아니다 — 2026-07-31, 2026-08-05, 2026-08-18 세 번 나왔다.
앞의 두 번은 문서로 안 남겨서 다음 세션에 잊혔다. 세 번째에 규칙이 됐다.
+**예외를 썼으면 무엇을 얻었는지 PR 본문에 적는다** (2026-08-22 추가). v1.3.0에서
+PR 스크린샷을 찍다가 KVS entitlement가 시뮬레이터 실행을 막고 있는 것을 발견했다 —
+실기기도 CI도 초록이던 버그다. 예외가 값을 한 사례이므로 목표 0을 기계적으로 밀지
+않는다. 대신 **얻은 것이 "그려졌다" 하나뿐이면 그건 낭비로 센다.**
+
+`xcrun simctl`을 Bash로 부르는 것도 시뮬레이터 사용이다. 지표(M11)가 아직 그 경로를
+안 세지만 규칙은 센다.
+
**대신 무엇을 하나.** 확인하고 싶은 것이 로직이면 테스트를 쓴다 (R7). 화면이면
사용자에게 넘긴다. **"확인했다"고 말할 수 있는 근거는 빌드 결과와 테스트 결과뿐이다.**
그 둘로 덮이지 않는 것은 확인 안 한 것이고, 그렇게 적는다.
@@ -233,7 +259,7 @@ R2로 시뮬레이터를 닫았으므로 **테스트가 AI가 쓸 수 있는 유
| 자연어 시각 파싱 | `4시~7시`의 종료 시각 · 오전/오후 해석 |
| 카테고리 분류 | 임계값 `0.35`에 근거 데이터가 없다 |
-**지금까지 덮은 것** (2026-08-19, 108건):
+**지금까지 덮은 것** (2026-08-22, 112건):
| 스위트 | 무엇을 잡나 |
|---|---|
@@ -292,9 +318,25 @@ AI는 자기 모델을 못 바꾼다. 바꾸는 것은 사용자다. AI가 할
한두 번이면 끝나는 것. 서브에이전트는 맥락 없이 시작하므로 **설명하는 비용이
직접 하는 비용보다 크면 지는 거래**다.
-**서브에이전트는 사용자가 요청할 때만 띄운다.** 그래서 이 규칙의 지금 몫은 절반이다 —
-띄우지 않은 경우에도 **"여기는 위임했으면 쌌다"를 남긴다.** 그 기록이 쌓여야 다음
-버전에 상시 위임 구성을 정할 수 있다. 보고서 6.3이 그 자리다.
+**조사·감사·전수 훑기는 묻지 않고 위임한다** (2026-08-22 개정). 예전엔 "사용자가
+요청할 때만"이었는데, 그 문장이 규칙의 실행 자체를 막아 M14가 세 버전 연속 0%였다.
+효과가 없었던 게 아니라 **한 번도 시험대에 못 올랐다.**
+
+기준은 이렇다.
+
+| 묻지 않고 위임 | 물어보거나 직접 |
+|---|---|
+| 파일 10개 이상을 읽어야 하는 조사 | 판단이 필요한 것 |
+| "이 심볼 쓰는 데 다 찾아줘" | 이미 열어둔 파일을 고치는 것 |
+| 명명 규칙·패턴 훑기, 감사 | 왕복 한두 번이면 끝나는 것 |
+| 읽은 내용 자체는 안 남겨도 되는 것 | 편집이 따르는 것 |
+
+2026-08-22에 넷을 병렬로 돌려 54만 토큰을 쓰고 요약만 받았다. 직접 읽었으면 그 54만이
+본 컨텍스트에 쌓여 이후 모든 턴이 다시 읽었을 것이다. 정의와 근거는
+`.claude/agents/README.md`.
+
+위임하지 **않은** 경우에도 **"여기는 위임했으면 쌌다"를 남긴다.** 그 기록이 쌓여야
+다음 버전에 구성을 조정할 수 있다. 보고서 6.3이 그 자리다.
**기록하는 자리는 PR 본문이다** (2026-08-19 변경). 예전엔 "답 끝에 한 줄"이었는데
매 턴 기억해야 하는 것이라 v1.2.0에서 **한 번도 안 남겼다.** PR 본문은 어차피 쓰는
@@ -312,6 +354,24 @@ AI는 자기 모델을 못 바꾼다. 바꾸는 것은 사용자다. AI가 할
- 이미 읽은 파일을 다시 읽지 않는다. 편집 도구는 실패하면 에러를 낸다 — 확인하려고
다시 읽는 것이 가장 흔한 낭비다.
+**Bash로 읽을 때도 같다.** `sed -n '100,140p'`·`grep -n`·`head`는 범위를 준 것이고
+`cat`은 아니다. 지표(M15)가 `Read` 도구만 세는 것은 지표의 한계지 규칙의 한계가
+아니다 (2026-08-22).
+
+### R11 · 진단하려고 넣은 것은 표식을 달고, 커밋 전에 걷는다
+담당 지표 M16 임시 코드 잔존
+
+원인을 좁히려고 로그·탐침·임시 분기를 넣을 때가 있다. **그것들은 진단이 끝나면
+쓰레기다.** v1.3.0에서 탐침 파일 하나와 추적 로그 두 줄이 남을 뻔했고, 사용자가
+"불필요한 코드는 제거하고"라고 말한 뒤에야 걷었다.
+
+- 넣을 때 **`// TEMP:` 표식**을 같이 단다. 왜 넣었는지도 한 줄 적는다.
+- 커밋 전에 `grep -rn "TEMP:" Mosco/`로 찾아 전부 걷는다.
+- **남길 가치가 있는 것은 표식을 떼고 이유를 주석으로 바꾼다.** 실패했을 때의 로그가
+ 대개 그렇다 — v1.3.0의 날씨 실패 로그는 남겼고, 매번 찍히는 추적 로그는 걷었다.
+
+`tools/quality_baseline.py`가 이 표식을 센다. 0이 아니면 커밋하지 않는다.
+
## 일하는 방식
- 사용자는 `auto`/`acceptEdits`로 거의 항상 열어둔다. 편집을 일일이 승인하지 않으므로
@@ -326,3 +386,7 @@ AI는 자기 모델을 못 바꾼다. 바꾸는 것은 사용자다. AI가 할
이건 취향이 아니라 M12로 채점되는 항목이다 (R8·R9·R10).
- **모든 작업은 브랜치와 PR을 거친다.** `main`에 직접 커밋하지 않는다.
규칙은 `CONTRIBUTING.md`.
+- **문서는 사람이 읽을 글로 쓴다** (2026-08-22 요청). 보고서·규범·계획 전부
+ 해당한다. 표와 목록으로만 채우지 말고, 왜 그런지를 문장으로 적는다. 딱딱한
+ 번역체("~를 수행한다", "~에 대한 검증")를 쓰지 않는다. **읽는 사람이 새벽에
+ 피곤한 상태라고 가정한다** — 한 번 읽고 이해되지 않으면 그 문서는 실패한 것이다.
diff --git a/README.md b/README.md
index 4c75840..a5bd397 100644
--- a/README.md
+++ b/README.md
@@ -37,9 +37,9 @@ Firebase Analytics를 쓰지만 `GoogleService-Info.plist`는 저장소에 들
(`FirebaseAnalyticsSink.configure()`가 nil을 낸다).
시뮬레이터는 이름으로 지정한다 — UDID는 지워졌다 다시 생기면서 바뀐다.
-검증 사다리는 빌드 → 테스트 → 사람 → 심사다. **`MoscoTests`(Swift Testing, 108건)와
-CI가 앞의 두 칸을 맡는다** — PR마다 App·MoscoWidget 빌드와 테스트가 자동으로 돈다
-([.github/workflows/ci.yml](.github/workflows/ci.yml)).
+검증 사다리는 빌드 → 테스트 → 사람 → 심사다. **`MoscoTests`(Swift Testing, 112건)와
+CI가 앞의 두 칸을 맡는다** — PR마다 App·MoscoWidget 빌드와 테스트, 그리고 Mac
+Catalyst 빌드가 자동으로 돈다 ([.github/workflows/ci.yml](.github/workflows/ci.yml)).
```bash
xcodebuild -project Mosco/Mosco.xcodeproj -scheme App -sdk iphonesimulator \
@@ -68,7 +68,7 @@ Mosco/MoscoTests/ 유닛 테스트 (Swift Testing)
| 문서 | 무엇이 있나 |
|---|---|
-| [CLAUDE.md](CLAUDE.md) | AI 작업 규칙 열 개 (R1~R10). 규칙마다 담당 지표가 있다 |
+| [CLAUDE.md](CLAUDE.md) | AI 작업 규칙 열한 개 (R1~R11). 규칙마다 담당 지표가 있다 |
| [CONTRIBUTING.md](CONTRIBUTING.md) | 커밋·브랜치·PR·문서 갱신 규칙 |
| [RELEASING.md](RELEASING.md) | 버전 규칙, 태그, 릴리스 노트 쓰는 법 |
| [docs/TRAPS.md](docs/TRAPS.md) | 한 번씩 크게 시간을 쓴 플랫폼 함정 |
diff --git a/RELEASING.md b/RELEASING.md
index 5e976f7..7ab45ec 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -79,7 +79,7 @@ App Store의 "새로운 기능" 칸에 들어가는 글입니다. 규칙은 넷
| | 절 구성 | 분량 |
|---|---|---|
| **MAJOR · MINOR** | 1~6절 전부 | 제한 없음 |
-| **PATCH** | 2절(지표) · 3절(원인) · 6절(다음 하네스) | 한 쪽 |
+| **PATCH** | 2절(지난 결과) · 3절(원인) · 4절(다음 하네스) | 한 쪽 |
PATCH 보고서에서 **하네스를 안 바꿨으면 "안 바꿨다"고 적습니다.** 매 버전 규칙을
늘리는 것이 목적이 아니고, 안 바꾼 것도 판단입니다.
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 5b1da4c..411486a 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -29,7 +29,8 @@
- [ ] 애플 워치 지원
- [ ] 결제 모듈
- [ ] 가격 A/B 테스트
-- [ ] 수출 규정 문서 이슈 — `Info.plist`에 `ITSAppUsesNonExemptEncryption` 넣어 해소
+- [x] 수출 규정 문서 이슈 — `Info.plist`에 `ITSAppUsesNonExemptEncryption`가 이미
+ 들어가 있다. 2026-08-22에 `tools/artifact_check.sh`로 확인했다
- [ ] 카테고리 분류 기준선 만들기 — 입력·기대 카테고리 쌍 30개. `docs/TRAPS.md` 참고
## 하네스를 만들면서 드러난 것
@@ -75,7 +76,8 @@
순서대로 한다. 위에서부터 반복해서 깨진 순이다.
-- [x] **테스트 타깃 생성** — `MoscoTests`, Swift Testing, TEST_HOST = App. App 스킴에
+- [x] **테스트 타깃 생성** — `MoscoTests`, Swift Testing, **호스트 앱 없음**
+ (`TEST_HOST`를 안 쓴다 — 예전 기록이 틀렸다, 2026-08-22 정정). App 스킴에
물려서 스킴 하나로 빌드와 테스트가 같이 돈다 (2026-08-18)
- [x] 날짜 경계 — 월말/월초에 같은 날짜가 두 번 나오지 않는다 · `MonthLayoutTests` 8건
- [x] 반복 일정 전개 — 3일짜리가 걸친 날짜 전부에 나온다 · `RecurrenceTests`
@@ -217,7 +219,7 @@
- [ ] App Store Connect에 Mac 버전 추가 — 새 앱이 아니라 지금 레코드에 macOS
플랫폼을 얹는다. Mac 스크린샷(1280×800 이상)과 '이 버전의 새로운 기능'은
따로 채워야 하고, 심사도 따로 한 번 더 받는다. 계정으로 하는 일이다
-- [ ] 수출 규정 — `Info.plist`에 `ITSAppUsesNonExemptEncryption`. 맥 빌드에도 묻는다
+- [x] 수출 규정 — 키가 이미 있다 (2026-08-22 확인)
- [~] 하루치 페이지 상단에 주간 스트립 — 77d0649에서 걷어냈던 `WeekStripView`를 되살린다.
달 전체로 접혔다 펴지는 구조는 되살리지 않는다
- [~] 주간 스트립 좌우 스와이프로 주 넘기기 — 페이지 전체 스와이프는 안 한다.
@@ -250,6 +252,32 @@
호스트 앱이 필요한데 테스트 타깃은 앱을 안 띄운다(CI 서명 때문에 그렇게 했다).
R7이 "UI 테스트도 만든다"고 적어둔 것과 이 구성이 부딪힌다. 정해야 한다
+### 전수 조사 뒤에 남은 것 (2026-08-22)
+
+계획은 `docs/architecture/REFACTOR-PLAN.md`에 있다. 여기에는 그 밖의 것만 적는다.
+
+- [ ] **권한 파일과 규칙을 맞춘다** — AI가 자기 권한 파일을 못 고치므로 사람이 해야
+ 한다. R2는 시뮬레이터를 금지하는데 `.claude/settings.json`의 `allow`에
+ `Bash(xcrun simctl boot *)`·`Bash(xcrun simctl io * screenshot *)`가 있고
+ `settings.local.json`에는 `Bash(xcrun simctl *)`가 있다. 규칙대로라면 PR
+ 스크린샷용 `io ... screenshot`과 읽기 전용 `list`만 남기고 나머지는 뺀다.
+ 반대로 R1이 매번 요구하는 `xcodebuild ... test`는 어느 `allow`에도 없어서
+ 매번 승인을 탄다 — 그건 넣는다. `Bash(git commit -m ' *)`는 "커밋은 요청받을
+ 때만"과 어긋나므로 뺄지 정한다
+- [ ] 지표 정의 넷 고치기 (M15·M6·M11·M4) + M16·M17·M18 구현.
+ 스키마를 올리고 이전 버전 전체를 다시 돌려야 한다 — `docs/harness/rules.md`
+- [ ] `tools/quality_baseline.py`·`tools/artifact_check.sh`를 CI에 물릴지 정하기.
+ 물리면 계약·품질 검증이 PR 시점으로 당겨진다
+- [ ] `deadcode_audit.py`가 어디에도 안 물려 있다 — 정기 실행 자리를 정한다
+- [ ] `StyleGuideView`(109줄)가 어디서도 안 열린다. `DesignSystem/README.md`는
+ "앱을 실행하면 뜬다"고 적혀 있는데 진입 경로가 없다. 넣든지 지우든지
+- [ ] `MonthGridCache`가 비우지 않는 정적 딕셔너리다. 무한히 자란다
+- [ ] `TodoActivityAttributes`에 `nonisolated`가 없다 — 프로세스 간 주고받는 값인데
+ 암묵적 MainActor다
+- [ ] 호스트 앱을 붙일지 정한다. 지금 R7은 "UI 테스트도 만든다"고 적어놓고 구조상
+ 못 한다 — 문서와 현실이 어긋난 상태다
+- [ ] AI 사용 보고서 v1.3.0을 위키에 올린다 (공개 저장소라 사람이 확인 후)
+
## 버림
diff --git a/docs/architecture/CURRENT.md b/docs/architecture/CURRENT.md
new file mode 100644
index 0000000..35da88d
--- /dev/null
+++ b/docs/architecture/CURRENT.md
@@ -0,0 +1,209 @@
+# 지금 구조
+
+2026-08-22에 소스 전체(Swift 106파일 13,964줄)를 훑고 정리한 것이다. 세 갈래로
+나눠 조사했다 — `App/Features/`, `Shared/`, `App/Common/` + `MoscoWidget/`.
+
+**이 문서는 진단이 아니라 지도다.** 무엇을 어떻게 고칠지는
+[`PATTERNS.md`](PATTERNS.md)에서 다룬다.
+
+## 한눈에
+
+```mermaid
+flowchart TB
+ subgraph T3["MoscoTests 타깃"]
+ TESTS["MoscoTests/
10파일 · 112건"]
+ end
+ subgraph T2["MoscoWidget 타깃"]
+ W["MoscoWidget/
8파일"]
+ end
+ subgraph T1["App 타깃"]
+ F["App/Features/
27파일 5,436줄"]
+ C["App/Common/
40파일"]
+ ROOT["RootTabView · SceneDelegate
AppDelegate"]
+ end
+
+ SH["Shared/
32파일"]
+
+ F --> C
+ F --> SH
+ C --> SH
+ ROOT --> F
+ ROOT --> C
+ W --> SH
+ TESTS --> SH
+
+ style SH fill:#e8e0ff,stroke:#8B5CF6,stroke-width:2px
+ style TESTS fill:#e0f0e0
+```
+
+**`Shared/`가 세 타깃 모두에 들어간다.** 그리고 테스트 타깃은 `Shared/`와
+`MoscoTests/`만 컴파일한다 — 호스트 앱이 없다.
+
+여기서 이 프로젝트의 **가장 중요한 구조적 사실**이 나온다.
+
+> **테스트를 쓸 수 있는 코드와 없는 코드가 이미 폴더로 갈려 있다.**
+> `Shared/`에 있으면 테스트할 수 있고, `App/`에 있으면 못 한다.
+
+레이어 이름을 새로 붙일 필요가 없다. 경계는 이미 있고, **문제는 로직이 잘못된 쪽에
+많이 있다**는 것이다.
+
+## 레이어별 현황
+
+### `Shared/` — 32파일, 세 타깃 공유
+
+| 갈래 | 개수 | 예 |
+|---|---|---|
+| SwiftData 모델 | 5 | `TodoItem`, `TodoCategory`, `TodoCalendar` + 확장 2 |
+| 순수 계산 | 14 | `MonthLayout`, `TimelineLayout`, `TimeExpressionParser`, `PermissionGate`, `CalendarEventExpander` |
+| 저장소·전역 접근 | 5 | `SharedModelContainer`, `ThemeStore`, `Analytics`, `TodoCompletionWriter` |
+| 시스템 연동 | 3 | `TodoLiveActivityController`, `TodoActivityAttributes`, 인텐트 |
+| 그 밖 | 5 | `Date+Korean`, `KoreanHoliday`, `ThemeColor`, `AnalyticsIdentity`, `CalendarDayTone` |
+
+**잘 되어 있는 것**: 순수 계산 14개는 대부분 테스트가 붙어 있고, `nonisolated`
+표시가 필요한 자리에 잘 붙어 있다. `TodoSnapshot`은 SwiftData 모델을 값 사본으로
+바꿔주는 경로를 일부러 열어둬서 테스트가 가능하다.
+
+**섞여 있는 것 7개**: 한 파일 안에 순수 로직과 부작용이 같이 있다.
+`Analytics`(이벤트 정의는 순수, 전역 상태는 아님), `AnalyticsIdentity`(결정 규칙은
+순수, 저장소 어댑터는 아님), `MonthLayout`(계산은 순수, 같은 파일의
+`MonthGridCache`는 무한히 자라는 정적 딕셔너리), `KoreanHoliday`(계산은 순수, 정적
+캐시가 붙어 있음), `CategoryColorPalette`, `TodoCalendar`, `TodoSnapshot`.
+
+**테스트가 없는데 진짜 로직인 것 9개**. 값 순서로 위 셋만 적으면,
+
+1. `KoreanHoliday` (199줄) — 음력 변환, 대체공휴일 4규칙, 연휴 겹침 회피. 주석이
+ 2025년 어린이날 사례를 콕 집어 설명하는데 그 케이스를 지키는 테스트가 없다.
+2. `TodoItem+Recurrence` (96줄) — **`TodoSnapshot` 쪽 복사본만 테스트가 있고 모델
+ 쪽은 한 줄도 없다.** 앱 화면·알림·위젯이 실제로 부르는 건 모델 쪽이다.
+3. `CalendarSnapshotBuilder` — 앱과 위젯이 같은 달력 그림을 그리게 하는 핵심.
+ 구성 요소는 각각 덮였는데 엮는 부분이 안 덮였다.
+
+### `App/Features/` — 27파일 5,436줄, 테스트 0
+
+화면 셋(Calendar 20파일, TodayTodo 1파일, Settings 4파일)이다. **이 폴더에는 테스트가
+하나도 없다.** 구조상 못 쓴다.
+
+가장 큰 문제는 **뷰 안에 비자명 로직이 50곳 넘게 있다**는 것이다. 갈래별로 세면
+날짜/시간 계산 13곳, 정렬·필터 8곳, 파싱·문자열 8곳, 레이아웃 산술 16곳,
+지속성·부수효과 9곳이다.
+
+특히 아픈 자리 셋을 꼽으면,
+
+- `QuickAddView:481-536` — 오전/오후 모호성 해소 56줄. `TimeExpressionParser`는
+ 원자 값만 주고 **정책은 전부 뷰 안에** 있다. 옆에 `TimeExpressionParserTests`
+ 21건이 이미 있는데 정작 정책은 안 덮인다.
+- `EventScheduleSheet:181-214` — 반복 × 종일 × 종료 조합의 날짜 확정. 틀리면
+ 데이터가 조용히 망가진다.
+- `TodayTodoScreen:64-157` — 파생 컬렉션 8개(지난 할 일, 디데이, 검색, 정렬). 전부
+ "말로 된 규칙"인데 검증이 없다.
+
+**같은 로직의 복사본 20건**도 여기서 나온다. 그중 실제로 갈라진 것이 하나 있다 —
+목록 정렬 비교자가 `TodayTodoScreen`은 2단, `DayTodosContentView`는 3단이다. 같은
+할 일이 화면에 따라 다른 자리에 설 수 있다.
+
+### `App/Common/` — 40파일
+
+디자인 시스템, 튜토리얼, ML, 날씨, 동기화, 알림, 애널리틱스, 리뷰, 클립보드.
+
+**시스템 API를 가짜로 바꿀 수 있는 곳은 둘뿐이다.** `Analytics`는 `AnalyticsSink`
+프로토콜이 있고, ML은 `CategoryClassifying` 프로토콜이 있다(다만 뷰가 구체 타입을
+직접 만들어서 주입이 안 된다). 날씨·알림·동기화는 시스템 타입을 안에서 직접 잡는다.
+
+그리고 **`Common/`은 App 타깃에만 있으므로 어차피 테스트에서 안 보인다.**
+
+### `MoscoWidget/` — 8파일
+
+`Shared/`만 보고 산다. `App/Common/`을 못 쓰므로 디자인 토큰도 못 쓴다 — 폰트·간격이
+전부 리터럴이다. 테마 색만 App Group UserDefaults를 통해 따라간다.
+
+## 결합이 센 다섯 곳
+
+1. **`RootTabView` (396줄)** — 앱의 유일한 조립 지점이자 조율자. 스토어 5개를 만들어
+ 주입하고, 그 외에 **최소 13가지 일**을 한다(탭 제어, 튜토리얼 강제 이동, 리뷰
+ 발사, 시딩, iCloud 중복 병합, 알림 재예약 키 계산, 라이브 액티비티 핑거프린트,
+ 위젯 리로드, 날씨 로드, 권한 순서, 분석 배출). 새 전역 상태가 생기면 반드시 이
+ 파일이 바뀌고, 이 뷰 없이는 어떤 화면도 프리뷰가 안 된다.
+
+2. **`TutorialStep` enum이 화면 레이아웃을 안다** — `holeInsets`의 `top: 66`은
+ `QuickAddView` 시간칩 높이이고, `holeRadius`는 각 뷰의 코너 반경이다. 입력창
+ 디자인을 바꾸면 이 enum을 고쳐야 하고, 단계를 하나 추가하면 열 군데를 동시에
+ 고쳐야 한다.
+
+3. **문자열 핑거프린트 두 개** — `RootTabView:55`와 `:86`이 `TodoItem` 필드를 손으로
+ 나열해서 "언제 다시 계산할지"를 정한다. 필드를 추가하고 여기 안 넣으면 조용히
+ 갱신이 안 된다. 주석이 메모 누락으로 실제 그 버그를 겪었다고 적어뒀다.
+
+4. **튜토리얼 창과 코디네이터의 양방향 싱글턴** — `PassthroughWindow.hitTest`가 매
+ 터치마다 `TutorialCoordinator.shared`를 읽고, 코디네이터는 창을 직접 켜고 끈다.
+ 둘 다 `static let shared`라 어느 쪽도 따로 검증할 수 없다.
+
+5. **디자인 토큰이 저장소 싱글턴에 묶여 있다** — `MoscoPalette.accent`가
+ `ThemeStore.shared`를 읽고, 그건 App Group UserDefaults다. 색 하나 참조하는 순수한
+ 뷰도 메인 액터와 App Group 설정을 요구한다.
+
+## 데이터 접근이 두 갈래다
+
+같은 앱 안에 **완전히 다른 두 방식**이 공존한다.
+
+| | 방식 | 쓰는 곳 |
+|---|---|---|
+| A | `TodoQueryBridge`가 리프에서 `@Query`를 격리하고, 값 스냅샷을 백그라운드에서 만든다 | 달력 화면 |
+| B | 뷰 본체에 `@Query`를 놓고 매 `body`마다 필터·정렬 | 오늘 할 일, 하루치, 빠른 입력 |
+
+A는 성능 문제를 겪고 나서 만든 구조이고 주석이 그 경위를 적어뒀다. B는 그전 방식이
+그대로 남은 것이다. 그리고 A의 주석은 "캘린더 필터는 여기 한 곳에서만"이라고
+말하는데 **실제로는 네 곳에서 각자 필터링하고 있다.**
+
+## 품질 기준선 (2026-08-22)
+
+리팩터링을 시작하기 전에 찍어두는 숫자다. 나중에 "좋아졌는가"에 답하려면 출발점이
+있어야 한다.
+
+| 항목 | 값 |
+|---|---|
+| Swift 파일 · 줄 | 106 · 13,964 |
+| 테스트 | 112건 / 9스위트 (전부 `Shared/` 대상) |
+| Features 폴더의 테스트 | **0** |
+| 뷰 안의 비자명 로직 | **50곳 이상** |
+| 한 파일 최대 책임 수 | **11** (`QuickAddView` 679줄) |
+| 500줄 넘는 파일 | 3 (`QuickAddView` 679, `TodayTodoScreen` 662, `SettingsScreen` 538) |
+| 같은 로직의 복사본 | **20건** |
+| Feature 간 순환 의존 | 1쌍 (Calendar ↔ Settings) |
+| 테스트 없는 순수 로직 | 9개 |
+| 시스템 API를 가짜로 못 바꾸는 모듈 | 4 (날씨·알림·동기화·저장소) |
+| 디자인 토큰 우회 | 폰트 33곳, 여백 42곳, 반경 3곳 |
+| 싱글턴·전역 가변 상태 | 11 |
+| 조건부 컴파일 분기 | 6 |
+
+두 가지 방법으로 셌고 값이 조금 다르다. **위 표는 사람(조사 에이전트)이 읽어서 센
+것**이고, `tools/quality_baseline.py`는 정규식으로 센다. 예를 들어 "뷰 안의 비자명
+로직"은 사람이 50곳, 스크립트가 71건으로 센다 — 스크립트는 한 함수 안의 여러 호출을
+따로 세고 `App/Common/`의 뷰도 포함한다.
+
+**추세를 볼 때는 스크립트 값을 쓴다.** 사람이 센 값은 매번 달라지고, 스크립트는 같은
+방법으로 센다. 아래가 스크립트 기준 2026-08-22 값이다.
+
+| 항목 | 스크립트 값 |
+|---|---|
+| 뷰 안의 비자명 로직 | 71 (날짜 36 · 정렬·필터 19 · 저장소 14 · 문자열 2) |
+| 500줄 넘는 파일 | 3 |
+| 싱글턴 | 4 |
+| 조건부 컴파일 | 15 |
+| 폰트 하드코딩 | 48 |
+| 숫자 여백 | 59 |
+| `// TEMP:` 잔존 | 0 |
+
+## 잘 되어 있는 것도 적는다
+
+진단만 적으면 다 뜯어고쳐야 할 것처럼 보이는데, 그렇지 않다.
+
+- **순수 로직을 뽑아 테스트하는 습관이 이미 자리 잡았다.** 112건이 그 증거이고,
+ `TimeExpressionParser`는 뷰 안에 있던 것을 꺼내서 21건을 붙인 실제 사례다.
+- **되돌린 결정을 문서로 남긴다.** `DesignSystem/README.md`와 `docs/TRAPS.md`가
+ 실제로 작동하고 있다 — 규범 드리프트 지표가 두 버전 연속 0이다.
+- **주석이 "왜"를 적는다.** 이 조사가 빨랐던 이유의 절반이 주석이다. 대부분의
+ 이상해 보이는 코드 옆에 그렇게 한 이유가 적혀 있었다.
+- **앱과 위젯이 계산을 공유한다.** `CalendarSnapshotBuilder`를 양쪽이 같이 써서 같은
+ 그림을 그린다. 이건 많은 앱이 못 하는 것이다.
+- **동시성 표시가 대체로 정확하다.** 기본이 `MainActor`인 설정에서 백그라운드로
+ 가야 하는 것마다 `nonisolated`가 붙어 있다.
diff --git a/docs/architecture/DECISIONS.md b/docs/architecture/DECISIONS.md
new file mode 100644
index 0000000..63ee222
--- /dev/null
+++ b/docs/architecture/DECISIONS.md
@@ -0,0 +1,219 @@
+# 결정할 것들
+
+2026-08-22 전수 조사 뒤에 정리한 판단 문서다. **읽고 고르기 위한 것**이라, 조사
+내용은 최소만 담고 나머지는 링크로 뺐다.
+
+세 부분이다 — 알고 있어야 하는 것 / 결정해야 하는 것 / 지금은 안 정해도 되는 것.
+
+---
+
+## 한 장 요약
+
+앱은 잘 돌고 있고 출시돼 있다. 지난 네 버전에서 만든 규칙 체계도 작동하고 있다 —
+프롬프트당 토큰이 두 구간 연속 절반이 됐고, 규범 드리프트는 두 버전 연속 0이다.
+
+문제는 **코드가 아니라 코드를 검증할 수 있는 범위**다. 화면 쪽 로직 71곳이 테스트를
+쓸 수 없는 자리에 있고, v1.3.0에서 잡힌 버그 넷 중 테스트로 잡을 수 있었던 것은
+0개였다.
+
+**결정해야 할 것은 여섯 개다.** 그중 셋은 지금 정해야 하고, 셋은 1~2단계가 끝난 뒤에
+정해도 된다. 아래에 선택지와 장단점을 적었다.
+
+---
+
+## 1부. 알고 있어야 하는 것 (결정이 아니라 사실)
+
+### 1-1. 테스트 가능한 코드와 아닌 코드가 이미 폴더로 갈려 있다
+
+테스트 타깃이 `Shared/`만 컴파일한다(호스트 앱 없음). **그래서 어떤 아키텍처를
+고르든 "테스트하려면 `Shared/`로 내려야 한다"가 먼저다.** 새 레이어 이름을 붙이는
+것으로는 아무것도 안 바뀐다.
+
+이 사실 하나가 아래 판단 대부분을 결정한다.
+
+### 1-2. 순수 함수 추출은 아키텍처 중립이다
+
+값 타입 + 순수 함수로 뽑아둔 것은 MVVM을 고르든 TCA를 고르든 클린을 고르든 **그대로
+쓰인다.** ViewModel이 부르든 리듀서가 부르든 UseCase가 부르든 같다.
+
+**즉 1·2단계 작업은 어떤 결정을 하든 낭비되지 않는다.** 단, "이참에 프로토콜도 만들고
+주입도 하고"로 번지면 그 순간 특정 구조를 반쯤 고른 게 된다.
+
+### 1-3. 이 저장소에서 실제로 일관성을 만든 것은 규범 문서였다
+
+`DesignSystem/README.md`가 색·문구를 붙들고 있고 규범 드리프트가 두 버전 연속 0이다.
+**아키텍처 이름이 한 일이 아니다.** 반대로 코드 배치에는 그런 문서가 없고, 그 결과
+데이터 접근이 두 갈래로 갈렸다.
+
+### 1-4. 지금 숫자
+
+| | 값 | 뜻 |
+|---|---|---|
+| 뷰 안의 비자명 로직 | **71곳** | 테스트를 쓸 수 없는 코드의 양 |
+| Features 폴더의 테스트 | **0** | 구조상 못 쓴다 |
+| 같은 로직의 복사본 | 20건 | 그중 1건은 이미 갈라졌다(정렬 순서) |
+| 500줄 넘는 파일 | 3 | 최대 책임 11개 |
+| 싱글턴 | 5 | 정당한 것 2, 편의 3 |
+| 데이터 접근 방식 | **2갈래** | 한 앱 안에 다른 두 구조 |
+| 테스트 | 112건 | 전부 `Shared/` 대상 |
+
+### 1-5. 사람만 할 수 있는 일이 둘 밀려 있다
+
+- **권한 파일이 규칙과 어긋나 있다.** R2는 시뮬레이터를 금지하는데 `allow`에
+ `xcrun simctl`이 열려 있다. AI는 자기 권한 파일을 못 고친다.
+- **App Store Connect에 macOS 플랫폼 추가**와 1.3.0 업로드.
+
+---
+
+## 2부. 결정해야 하는 것
+
+### D1. 순서 — 추출을 먼저 할 것인가, 아키텍처를 먼저 고를 것인가
+
+| | 장점 | 단점 |
+|---|---|---|
+| **A. 추출 먼저** (권고) | 되돌리기 쉽고 낭비가 없다(1-2). 추출하면서 도메인의 실제 모양이 드러나 아키텍처 판단의 근거가 생긴다 | 중간에 "이걸 왜 하고 있나" 구간이 생길 수 있다 |
+| B. 아키텍처 먼저 | 목표가 분명하다. 배치 규칙이 곧바로 생긴다 | 모양을 모르는 로직을 틀에 맞춰 뽑게 된다. 틀이 틀리면 두 번 일한다 |
+
+**권고: A.** 다만 "아무 규칙 없이 추출"이 아니라 **D2와 같이** 간다.
+
+**미루면**: 지금 상태 유지. 새 기능마다 뷰 안 로직이 늘어난다.
+
+---
+
+### D2. 규칙을 어떤 형태로 둘 것인가
+
+"아키텍처 없음"과 "규칙 없음"은 다르다. 지금은 **규칙이 암묵적**이라 데이터 접근이
+두 갈래로 갈렸다.
+
+| | 장점 | 단점 |
+|---|---|---|
+| **A. 코드 배치 규범 문서** (권고) | 이 저장소에서 이미 작동이 증명된 방식(1-3). 점진적으로 채울 수 있다. 미정인 것을 "미정"으로 남길 수 있다 | 이름이 주는 공유 어휘가 없다. 강제력이 약하다 |
+| B. 이름 붙은 아키텍처 (MVVM 등) | 새로 오는 사람이 절반을 짐작한다. 배치 질문에 기본 답이 있다 | **이름은 절반만 답한다** — `@Query`는 어디, 시트 상태는 어디, 위젯은 어떻게가 여전히 남는다. 그리고 제약 1-1 때문에 MVVM은 이 프로젝트에서 얻는 게 적다 |
+| C. 둘 다 | 표지와 세부를 같이 가진다 | 지금 정하면 근거 없이 이름을 고르게 된다 |
+
+**권고: A로 시작하고, 1~2단계 뒤에 이름이 필요한지 다시 본다.**
+
+**중요한 단서**: **혼자가 아니게 될 계획이 있으면 저울이 B로 기운다.** 사람이 한 명만
+붙어도 공유 어휘의 값이 크게 오른다. 그 계획이 있으면 지금 알려달라.
+
+---
+
+### D3. 호스트 앱을 붙일 것인가 (UI 테스트)
+
+**지금 문서와 현실이 어긋나 있다.** R7은 "UI 테스트도 만든다"고 적어놨는데 구조상
+못 한다. 어느 쪽이든 정해야 문서가 거짓말을 멈춘다.
+
+| | 장점 | 단점 |
+|---|---|---|
+| A. 붙인다 | 제스처·화면 흐름이 검증 대상이 된다. v1.3.0에서 세 번 물린 종류가 덮인다 | **예전에 이것 때문에 CI가 죽었다**(서명 없어 저장소 생성 실패). 테스트가 느려지고 불안정해진다 |
+| **B. 별도 UI 테스트 타깃을 새로 만든다** (권고) | 지금 112건은 호스트 없이 빠르게 유지. UI 테스트만 따로 느리게 돈다 | 타깃이 하나 늘고 CI 시간이 는다 |
+| C. 안 붙인다 | 지금 구성이 단순하게 유지된다 | **R7에서 "UI 테스트"를 빼야 한다.** 제스처는 영원히 사람이 본다 |
+
+**권고: B.** 다만 **1~2단계 뒤에** 한다 — 그때쯤 뷰가 얇아져 UI 테스트가 덜 필요할
+수도 있다.
+
+**미루면**: R7의 그 문장을 지금 지워야 한다. 지켜지지 않는 문장을 남겨두지 않는 것이
+이 체계의 요점이다.
+
+---
+
+### D4. 검증 도구를 CI에 물릴 것인가
+
+어젯밤 만든 두 스크립트(`quality_baseline.py`, `artifact_check.sh`)는 지금 손으로만
+돈다.
+
+| | 장점 | 단점 |
+|---|---|---|
+| **A. 둘 다 CI에** (권고) | 계약 검증이 PR 시점으로 당겨진다. **v1.3.0의 버그 둘을 잡았을 것이다.** 품질 숫자가 매 PR에 남아 추세가 보인다 | CI가 30초쯤 는다. 기준선이 나빠지면 PR이 빨개져 성가실 수 있다 |
+| B. 계약 검사만 CI에 | 위험한 것만 막는다 | 품질 추세는 손으로 재야 한다 |
+| C. 안 물린다 | 지금 그대로 | 만들어놓고 안 쓰게 된다. `deadcode_audit.py`가 이미 그 신세다 |
+
+**권고: A.** 단 품질 검사는 **실패시키지 말고 기록만** 한다(숫자를 PR에 남기는 정도).
+기준선이 올라갔다고 빨간불이면 리팩터링 중에 계속 걸린다.
+
+---
+
+### D5. 싱글턴을 어디까지 정리할 것인가
+
+정당한 것 둘(위젯·인텐트처럼 **시스템이 밖에서 부르는 진입점**)과 편의 셋으로
+갈린다.
+
+| | 무엇 | 비용 | 얻는 것 |
+|---|---|---|---|
+| **A. 두 줄만** (권고, 지금) | `TodoActions`가 이미 주입된 `@Environment`를 쓰게 | 2줄 | 일관성. 위험 0 |
+| B. + 튜토리얼 창 | 코디네이터가 창을 소유 | 20줄 | 양방향 참조가 끊긴다 |
+| C. + 테마 색 | `ThemeStore`에 갈아끼울 자리 | 10줄 | 테마 색 테스트 가능. 호출부 29곳은 안 건드림 |
+| D. 전부 | 정당한 둘도 프로토콜 뒤로 | 중간 | 인텐트·위젯 경로까지 테스트 가능 |
+
+**권고: 지금 A, 1~2단계와 같이 B·C, D는 나중.**
+
+---
+
+### D6. PR #21을 병합할 것인가
+
+문서와 도구는 위험이 없는데, **`CLAUDE.md` 규칙 변경 넷이 들어 있다.** 이건 앞으로
+AI의 행동이 달라지는 것이라 따로 본다.
+
+| 변경 | 무엇이 달라지나 | 위험 |
+|---|---|---|
+| R1 확장 | 증상이 안 바뀌면 다음 가설 전에 경로가 도는지 확인 | 없음. 왕복이 준다 |
+| R2 단서 | 예외를 썼으면 무엇을 얻었는지 PR에 적는다 | 없음 |
+| **R9 개정** | **조사·감사는 묻지 않고 위임한다** | 잘못 위임하면 재작업이 는다. M14와 M1을 같이 봐야 한다 |
+| R11 신설 | 진단용 코드에 `// TEMP:` 달고 커밋 전에 걷는다 | 없음 |
+
+**권고: 병합.** R9만 한 버전 지켜보고, M1이 오르면 조건을 좁힌다.
+
+---
+
+## 3부. 지금은 안 정해도 되는 것
+
+미루는 게 손해가 아닌 것들이다. **1~2단계 뒤에 보면 답이 달라질 가능성이 높다.**
+
+| | 왜 미뤄도 되나 |
+|---|---|
+| TCA 도입 | 이 앱의 문제는 상태 흐름이 복잡한 게 아니라 로직이 테스트 못 하는 자리에 있는 것이다. 그건 훨씬 싼 방법으로 풀린다 |
+| SPM 모듈로 쪼개기 | 정리 안 된 것을 지금 쪼개면 그대로 굳는다. 경계가 보인 뒤에 |
+| Repository 도입 | `@Query`를 포기해야 한다. 추출하다 보면 정말 필요한지가 드러난다 |
+| MVVM | 제약 1-1 때문에 지금은 얻는 게 적다. 호스트 앱을 붙이기로 하면 다시 본다 |
+| Coordinator | 화면이 셋이라 이동 규칙이 단순하다. 화면이 두 배가 되면 |
+
+---
+
+## 권고 요약
+
+| | 결정 | 권고 | 언제 |
+|---|---|---|---|
+| D1 | 순서 | **추출 먼저** | 지금 |
+| D2 | 규칙 형태 | **배치 규범 문서**, 이름은 나중 | 지금 |
+| D3 | 호스트 앱 | **별도 UI 테스트 타깃** | 1~2단계 뒤 |
+| D4 | CI 도구 | **둘 다, 품질은 기록만** | 지금 |
+| D5 | 싱글턴 | **두 줄만 지금**, 나머지는 같이 | 지금/뒤 |
+| D6 | PR #21 | **병합**, R9는 한 버전 관찰 | 지금 |
+
+**지금 답이 필요한 것은 D1·D2·D4·D6 넷이고, 나머지 둘은 나중에 봐도 된다.**
+
+---
+
+## 아무것도 안 하면 어떻게 되나
+
+정직하게 적는다. **당장은 아무 일도 안 일어난다.** 앱은 돌고 기능도 계속 낼 수 있다.
+
+다만 셋이 계속 쌓인다.
+
+1. **뷰 안 로직이 는다.** 새 기능마다 몇 곳씩 는다. 지금 71곳이고, 지난 세 버전에서
+ 화면 코드가 5,436줄이 됐다.
+2. **복사본이 갈라진다.** 이미 정렬 순서가 화면마다 다르다. 이런 건 사람이 눈으로
+ 찾기 전엔 안 걸린다.
+3. **검증을 사람이 진다.** 테스트가 못 덮는 종류가 나올 때마다 검증 카드가 나가고,
+ 그건 새벽에 밟아야 하는 일이다. v1.3.0에 카드가 9건 나갔다.
+
+---
+
+## 같이 보는 문서
+
+- [CURRENT.md](CURRENT.md) — 조사 결과 전체
+- [PATTERNS.md](PATTERNS.md) — 설계 13가지 비교
+- [REFACTOR-PLAN.md](REFACTOR-PLAN.md) — 단계별 계획
+- [../verification.md](../verification.md) — 검증 체계
+- [../review-criteria.md](../review-criteria.md) — 사람이 봐야 하는 코드
+- [../harness/reports/v1.3.0.md](../harness/reports/v1.3.0.md) — 이번 버전 보고서
diff --git a/docs/architecture/PATTERNS.md b/docs/architecture/PATTERNS.md
new file mode 100644
index 0000000..51a8e17
--- /dev/null
+++ b/docs/architecture/PATTERNS.md
@@ -0,0 +1,341 @@
+# 쓸 수 있는 설계와 패턴
+
+나중에 비교해서 고르기 위한 목록이다. **여기서 아무것도 결정하지 않는다.** 각각이
+무엇이고, 이 프로젝트에 넣으면 무엇이 어떻게 바뀌고, 무엇을 얻고 무엇을 잃는지만
+적는다.
+
+읽기 전에 [`CURRENT.md`](CURRENT.md)의 "품질 기준선"을 보면 숫자로 비교할 수 있다.
+
+## 고를 때 걸리는 제약 다섯
+
+패턴을 평가하려면 먼저 이 프로젝트가 이미 가진 조건을 알아야 한다. 이게 점수표의
+기준이 된다.
+
+| # | 제약 | 왜 중요한가 |
+|---|---|---|
+| 1 | **테스트 타깃은 `Shared/`만 컴파일한다** (호스트 앱 없음) | 어떤 패턴을 고르든, 테스트하고 싶은 코드는 물리적으로 `Shared/`에 있어야 한다. 이게 가장 센 제약이다 |
+| 2 | **위젯이 같은 `Shared/`를 쓴다** | 도메인 로직에 UIKit·앱 전용 의존이 끼면 위젯이 깨진다 |
+| 3 | **SwiftData `@Query`에 이미 깊이 기대고 있다** | `@Query`는 뷰에서만 산다. 데이터 접근을 추상화하려면 이걸 어떻게 할지부터 답해야 한다 |
+| 4 | **1인 + AI가 주 작성자** | 보일러플레이트를 치는 비용은 싸다. 대신 **읽어야 하는 파일이 늘면 토큰이 든다** — 사람에게도 AI에게도 |
+| 5 | **이미 출시된 앱이고 계속 기능을 낸다** | 몇 주 멈추고 재작성하는 선택지는 없다. 기능을 내면서 옮겨야 한다 |
+
+---
+
+## A. 전체 구조를 정하는 패턴
+
+### A1. 지금 방식 — SwiftUI 네이티브 (MV)
+
+뷰가 상태를 직접 들고, `@Observable` 스토어를 `@Environment`로 나눠 쓰고, 데이터는
+`@Query`로 바로 읽는다. 별도 ViewModel 층이 없다.
+
+**장점**
+- SwiftUI가 의도한 방식이라 프레임워크와 안 싸운다. `@Query`의 자동 갱신, 애니메이션,
+ 프리뷰가 그대로 작동한다.
+- 파일 수가 적다. 화면 하나가 파일 하나다.
+- 새 기능을 붙이는 속도가 가장 빠르다.
+
+**단점**
+- **뷰 안의 로직을 테스트할 수 없다.** 지금 50곳 이상이 그 상태다.
+- 화면이 커지면 책임이 무한히 쌓인다. `QuickAddView` 679줄에 책임 11개가 그 결과다.
+- 같은 로직이 화면마다 복제된다. 지금 20건.
+
+**비용**: 0 (이미 이 방식이다)
+
+**적합도**: 유지하되 **로직만 밖으로 빼면** 단점 대부분이 사라진다. 아래 B2와 짝이다.
+
+---
+
+### A2. MVVM — 화면마다 ViewModel
+
+각 화면에 `@Observable` ViewModel을 두고, 뷰는 그리기만 한다.
+
+**장점**
+- 익숙하고 설명이 필요 없다.
+- 화면 로직을 클래스로 옮기니 **원칙적으로는** 테스트 가능해진다.
+
+**단점**
+- **이 프로젝트에서는 그 "원칙적으로"가 안 통한다.** ViewModel을 `App/Features/`에
+ 두면 테스트 타깃이 못 본다(제약 1). `Shared/`로 내리면 위젯도 그걸 컴파일한다(제약 2).
+- `@Query`를 ViewModel에서 못 쓴다. 뷰가 데이터를 받아 ViewModel에 밀어 넣는 구조가
+ 되고, 그러면 "뷰가 얇아진다"는 목적이 반쯤 무너진다.
+- 파일이 두 배가 된다. 화면 3개면 괜찮은데 시트·행까지 포함하면 27 → 50개.
+
+**비용**: 중 (화면 3개 + 주요 시트 4개 ≈ 2~3일)
+
+**적합도**: **낮다.** 제약 1 때문에 얻는 게 생각보다 적다. 호스트 앱을 붙이기로
+결정하면 그때 다시 볼 만하다.
+
+---
+
+### A3. TCA (The Composable Architecture)
+
+상태·액션·리듀서로 단방향 흐름을 만들고, 의존성을 명시적으로 주입한다. 외부
+라이브러리다.
+
+**장점**
+- 테스트가 이 라이브러리의 존재 이유다. 상태 전이를 전부, 빠짐없이 검증할 수 있다.
+- 의존성 주입이 설계에 내장돼 있다 — 날씨·알림·동기화를 가짜로 바꾸는 문제가 통째로
+ 풀린다.
+- 부수효과가 한 자리에 모인다. 지금 뷰에 흩어진 9곳이 여기로 온다.
+
+**단점**
+- **외부 의존이 하나 늘고, 그게 앱 구조 전체를 정한다.** 나중에 빼는 것이 사실상
+ 불가능하다.
+- 빌드 시간과 컴파일 부담이 는다. 지금 CI가 5분인데 더 늘어난다.
+- 러닝 커브가 있고, 규칙이 많아 AI가 쓸 때도 실수하면 재작업이 크다.
+- `@Query`와 궁합이 나쁘다. SwiftData를 TCA 방식으로 감싸는 별도 작업이 필요하다.
+- 코드량이 늘어난다 — 같은 기능에 상태·액션·리듀서가 붙는다.
+
+**비용**: 대 (전면 도입이면 몇 주. 부분 도입도 화면 하나에 며칠)
+
+**적합도**: **낮다.** 이 앱의 문제는 "상태 흐름이 복잡해서 못 따라가겠다"가 아니라
+"로직이 테스트 못 하는 자리에 있다"다. TCA는 전자를 푸는 도구고, 후자는 훨씬 싼
+방법으로 풀린다.
+
+---
+
+### A4. 레이어드 / 클린 아키텍처
+
+Domain(순수) / Data(저장소) / Presentation(화면)으로 나누고 의존 방향을 안쪽으로만
+둔다.
+
+**장점**
+- **이 프로젝트는 이미 절반쯤 이 모양이다.** `Shared/`가 Domain, `App/`이
+ Presentation, `SharedModelContainer`가 Data다. 이름만 안 붙였을 뿐이다.
+- 제약 1과 정확히 맞아떨어진다 — 테스트 가능한 층이 물리적으로 갈려 있다.
+- 위젯이 Domain만 쓰는 지금 구조와도 맞는다.
+
+**단점**
+- 엄격하게 하면 `@Query`를 쓸 수 없다. Repository를 통해 데이터를 받아야 하는데,
+ 그러면 SwiftData의 자동 갱신을 손으로 다시 만들어야 한다.
+- 층을 넘는 변환 코드(DTO ↔ 모델)가 는다.
+- 과하게 적용하면 파일 다섯 개를 열어야 한 줄을 고칠 수 있게 된다.
+
+**비용**: 중~대. 다만 **점진적으로 가능**하다 — 로직을 하나씩 `Shared/`로 내리면
+그게 곧 이 패턴이다.
+
+**적합도**: **높다.** 단, "클린 아키텍처를 도입한다"가 아니라 **"이미 있는 경계를
+분명히 하고 잘못된 쪽에 있는 것을 옮긴다"**로 읽을 때만.
+
+---
+
+### A5. VIPER 등 더 무거운 것
+
+**적합도**: 없음. 1인 프로젝트에 5층 구조는 읽는 비용만 는다. 기록용으로만 적는다.
+
+---
+
+## B. 부분만 도입할 수 있는 패턴
+
+전체 구조를 안 바꾸고 하나씩 넣을 수 있는 것들이다. **이 프로젝트에는 이쪽이 훨씬
+현실적이다.**
+
+### B1. 순수 로직 추출 — `Shared/`로 내리기
+
+뷰 안의 계산을 `nonisolated struct`/`enum`으로 뽑아 `Shared/`에 둔다.
+
+**이미 해본 것이다.** `TimeExpressionParser`가 `QuickAddView` 안의 `private static`
+이었는데 꺼내니 테스트 21건이 붙었다. `TimelineLayout`, `MonthLayout`,
+`PermissionGate`도 같은 경로였다.
+
+**장점**
+- 제약 1·2를 정면으로 만족한다. 옮기는 즉시 테스트가 가능해진다.
+- 점진적이다. 오늘 하나 옮기고 내일 하나 옮겨도 된다.
+- 되돌리기 쉽다. 옮긴 것이 아니다 싶으면 다시 넣으면 된다.
+- **AI가 하기 가장 쉬운 종류의 작업이다** — 규칙이 명확하고 컴파일러가 검증한다.
+
+**단점**
+- 구조 문제(RootTabView가 13가지를 한다)는 안 풀린다.
+- 뷰와 로직 사이에 값 타입을 하나씩 만들어야 할 때가 있다.
+
+**비용**: 소~중. 대상 50곳 중 값이 큰 13곳부터 하면 2~3일.
+
+**적합도**: **가장 높다.** 첫 순서로 추천.
+
+---
+
+### B2. Repository / DataSource 추상화
+
+SwiftData 접근을 프로토콜 뒤로 숨긴다.
+
+**장점**
+- 저장소를 가짜로 바꿀 수 있다. 지금 못 하는 것 중 큰 것.
+- 캘린더 필터가 네 곳에 흩어진 문제가 한 곳으로 모인다.
+
+**단점**
+- **`@Query`를 포기해야 한다.** 자동 갱신을 손으로 만들면 그 자체가 새 버그 밭이다.
+ `CalendarSnapshotStore`가 이미 그 길을 절반 갔고, 그 파일 주석이 왜 그렇게
+ 힘들었는지 적어뒀다.
+- 위젯도 같은 추상화를 써야 하는데 위젯은 타임라인마다 한 번 읽고 끝이라 요구가 다르다.
+
+**비용**: 중~대
+
+**적합도**: **중간.** 전면 도입은 위험하고, **읽기 전용 조회(필터·정렬)만** 값 타입
+함수로 뽑는 절충이 현실적이다. 그건 사실상 B1이다.
+
+---
+
+### B3. 의존성 주입 정리
+
+지금은 세 방식이 섞여 있다 — `RootTabView`가 만들어 `@Environment`로 주입, 싱글턴
+직접 참조, 뷰가 직접 생성. 하나로 모은다.
+
+**장점**
+- 프리뷰가 살아난다. 지금은 `@Environment` 다섯 개가 없으면 화면이 크래시한다.
+- 테스트에서 가짜를 넣을 길이 생긴다.
+- `SettingsScreen`이 테마만 싱글턴으로 읽는 것 같은 예외가 사라진다.
+
+**단점**
+- 조립 코드가 는다.
+- `Analytics`처럼 어디서나 부르는 전역은 주입으로 바꾸면 호출부가 전부 바뀐다.
+
+**비용**: 소~중 (스토어 7개 + 싱글턴 11개 정리)
+
+**적합도**: **높다.** B1 다음 순서로 좋다.
+
+---
+
+### B4. Coordinator / Router
+
+네비게이션을 뷰에서 빼낸다.
+
+**장점**: 화면 이동 규칙이 한 곳에 모인다. 지금 Calendar ↔ Settings 순환 의존이 풀린다.
+
+**단점**: SwiftUI의 `NavigationStack`·`sheet`와 겹친다. 이 앱은 화면이 3개 + 시트
+몇 개라 이동 규칙 자체가 단순하다.
+
+**비용**: 중
+
+**적합도**: **낮다.** 지금 규모에서는 얻는 게 적다. 화면이 두 배가 되면 다시 본다.
+
+---
+
+### B5. 단방향 흐름만 빌려오기 (State/Action, 라이브러리 없이)
+
+TCA를 안 쓰고 개념만 가져온다. 화면 상태를 struct 하나로 모으고, 변경을 함수로만
+하게 한다.
+
+**장점**
+- 상태 struct와 변경 함수가 `Shared/`로 내려가면 **테스트 가능해진다.**
+- 라이브러리 의존이 없다.
+- `QuickAddView`의 `@State` 15개 같은 자리에 정확히 맞는다.
+
+**단점**
+- 직접 만든 규칙이라 강제하는 것이 없다. 다음 사람이(=다음 세션의 AI가) 안 지킬 수 있다.
+- SwiftUI 바인딩과 이어 붙이는 코드가 필요하다.
+
+**비용**: 중 (화면당 반나절~하루)
+
+**적합도**: **중간~높음.** 특히 상태가 많은 화면 두 곳(`QuickAddView`,
+`TodayTodoScreen`)에 국소 적용하면 값이 크다.
+
+---
+
+## C. 물리적 분리
+
+### C1. SPM 로컬 패키지로 모듈 쪼개기
+
+`Domain`, `DesignSystem` 같은 로컬 패키지를 만들고 앱이 그걸 가져다 쓴다.
+
+**장점**
+- 의존 방향이 **컴파일러로 강제**된다. 문서로 부탁하는 것과 차원이 다르다.
+- 패키지 단위로 테스트 타깃을 붙일 수 있다 — **제약 1이 풀린다.** 호스트 앱 없이도
+ `DesignSystem` 테스트가 가능해진다.
+- 빌드 캐시가 쪼개져 증분 빌드가 빨라질 수 있다.
+
+**단점**
+- Xcode 프로젝트 구조를 크게 건드린다. 지금 폴더 동기화 방식(`fileSystemSynchronizedGroups`)을
+ 다시 짜야 한다.
+- 위젯·앱·테스트의 멤버십을 전부 다시 정해야 한다.
+- 한 번 쪼개면 되돌리기 어렵다.
+
+**비용**: 대 (2~4일 + 이후 잔버그)
+
+**적합도**: **나중에.** B1·B3으로 로직을 정리한 뒤, "이제 경계를 못 넘게 막자"가
+필요해질 때. 지금 하면 정리 안 된 것을 그대로 굳힌다.
+
+---
+
+### C2. 테스트 호스트 앱 붙이기
+
+테스트 타깃에 `TEST_HOST`를 붙여 앱을 띄우고 테스트한다.
+
+**장점**
+- `App/Common/`과 `App/Features/`가 테스트 사정권에 들어온다.
+- **XCUITest를 쓸 수 있다** — 지금 R7이 "UI 테스트도 만든다"고 적어놓고 못 하고 있다.
+- 제스처·화면 흐름 같은, v1.3.0에서 세 번 물린 종류가 덮인다.
+
+**단점**
+- **예전에 이것 때문에 CI가 죽었다.** 서명이 없어 저장소를 못 만들었고, 로컬에서도
+ CloudKit 오류가 났다. `CLAUDE.md`에 그 경위가 적혀 있다.
+- 테스트가 느려지고 불안정해진다.
+- 지금 112건이 호스트 없이 도는 이점을 잃을 수 있다(타깃을 나누면 유지 가능).
+
+**비용**: 중 (CI 서명 문제 해결이 대부분)
+
+**적합도**: **결정이 필요한 항목.** 지금은 문서와 현실이 어긋난 상태라 어느 쪽이든
+정해야 한다. 절충안은 **유닛 테스트 타깃은 그대로 두고 UI 테스트 타깃을 따로 만드는
+것**이다.
+
+---
+
+## D. 검증 쪽 패턴
+
+### D1. 스냅샷(골든) 테스트
+
+뷰를 이미지로 렌더해 기준 이미지와 비교한다.
+
+**장점**: 레이아웃 회귀를 기계가 잡는다. 지금 사람이 카드로 보는 것의 일부가 넘어온다.
+**단점**: 호스트 앱이 필요하고(C2), 기준 이미지 관리 비용이 있고, OS 버전이 오르면
+전부 깨진다.
+**적합도**: 중간. C2를 하기로 하면 같이 검토.
+
+### D2. 산출물 검사 스크립트
+
+entitlements·plist·버전 일치를 빌드 결과에서 확인한다.
+**적합도**: **높다.** [`../verification.md`](../verification.md)에 근거를 적었다.
+v1.3.0의 버그 두 개를 이것 하나가 잡았을 것이다.
+
+### D3. 정적 품질 검사
+
+[`CURRENT.md`](CURRENT.md)의 기준선 일곱 개를 매번 세는 스크립트.
+**적합도**: **높다.** 리팩터링 전에 기준선을 찍는 용도로 먼저 필요하다.
+
+---
+
+## 한눈에 비교
+
+| | 얻는 것 | 잃는 것 | 비용 | 되돌리기 | 제약 1 해결 | 추천 순서 |
+|---|---|---|---|---|---|---|
+| **B1** 순수 로직 추출 | 테스트 가능 영역 | 없음 | 소~중 | 쉬움 | ✅ | **1** |
+| **D3** 정적 품질 검사 | 기준선·추세 | 없음 | 소 | 쉬움 | — | **2** |
+| **D2** 산출물 검사 | 계약 검증 | 없음 | 소 | 쉬움 | — | **3** |
+| **B3** 의존성 주입 정리 | 프리뷰·가짜 주입 | 조립 코드 증가 | 소~중 | 보통 | 부분 | **4** |
+| **B5** 단방향 흐름(국소) | 화면 상태 테스트 | 직접 만든 규칙 | 중 | 보통 | ✅ | 5 |
+| **A4** 레이어 분명히 | 방향 일관성 | 변환 코드 | 중 | 보통 | ✅ | 6 (B1의 결과) |
+| **C2** 호스트 앱 | UI·제스처 검증 | CI 취약성 | 중 | 어려움 | ✅ | 결정 필요 |
+| **C1** SPM 모듈 | 컴파일러 강제 | 프로젝트 재구성 | 대 | 어려움 | ✅ | 나중 |
+| **B2** Repository | 저장소 교체 | `@Query` 포기 | 중~대 | 어려움 | 부분 | 보류 |
+| **A2** MVVM | (제약 1 때문에 적음) | 파일 2배 | 중 | 보통 | ✕ | 보류 |
+| **B4** Coordinator | 순환 의존 해소 | SwiftUI와 중복 | 중 | 보통 | ✕ | 보류 |
+| **A3** TCA | 완전한 상태 검증 | 외부 의존·빌드 시간 | 대 | 매우 어려움 | ✅ | 권하지 않음 |
+
+## 제안하는 조합 (결정은 같이)
+
+**1단계 — 기준선과 안전망** (코드는 거의 안 건드린다)
+D3으로 지금 숫자를 찍고, D2로 계약 검증을 커밋 시점으로 당긴다.
+
+**2단계 — 로직을 옮긴다** (B1)
+`CURRENT.md`가 지목한 13곳을 값 순서로 `Shared/`에 내리고, 내릴 때마다 테스트를
+같이 쓴다. **이 단계에서 파일 구조는 안 바꾼다.**
+
+**3단계 — 조립을 정리한다** (B3)
+`RootTabView`의 13가지 책임을 쪼개고, 싱글턴 11개를 주입으로 모은다.
+
+**4단계 — 그때 다시 판단한다** (C1/C2/B5)
+2·3단계가 끝나면 남은 문제가 무엇인지 지금과 다르게 보일 것이다. 모듈 분리도
+호스트 앱도 그때 결정한다.
+
+**왜 이 순서인가**: 1·2단계는 되돌리기 쉽고 지금 있는 제약을 하나도 안 건드린다.
+3단계부터 구조가 바뀌는데, 그때는 이미 테스트가 늘어 있어서 안전하다. **가장 위험한
+것(모듈 분리·호스트 앱)을 가장 늦게** 둔 것이 이 순서의 요점이다.
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
new file mode 100644
index 0000000..53cfc89
--- /dev/null
+++ b/docs/architecture/README.md
@@ -0,0 +1,37 @@
+# 아키텍처 문서
+
+리팩터링을 시작하기 전에 지금을 기록해두는 자리다. 2026-08-22에 소스 전체를 훑고
+만들었다.
+
+| 문서 | 무엇이 있나 |
+|---|---|
+| [DECISIONS.md](DECISIONS.md) | **판단 문서.** 무엇을 정해야 하고 선택지가 무엇인지. 여기부터 읽는다 |
+| [CURRENT.md](CURRENT.md) | **지금 구조.** 레이어별 현황, 결합이 센 다섯 곳, 품질 기준선 |
+| [PATTERNS.md](PATTERNS.md) | **쓸 수 있는 설계와 패턴.** 13가지를 장단점·비용·적합도로 비교 |
+| [REFACTOR-PLAN.md](REFACTOR-PLAN.md) | **무엇을 어떤 순서로 고칠 것인가.** 위험이 낮은 것부터 다섯 단계 |
+
+읽는 순서는 `CURRENT.md` → `PATTERNS.md`다. 지금 무엇이 문제인지 모른 채 패턴을
+고르면 유행을 고르게 된다.
+
+## 같이 보는 문서
+
+- [`../verification.md`](../verification.md) — 무엇을 어떻게 검증하나. 테스트 코드만으로
+ 안 되는 이유와 그 대안
+- [`../review-criteria.md`](../review-criteria.md) — 사람이 봐야 하는 코드와 안 봐도
+ 되는 코드
+- [`../workflow.md`](../workflow.md) — 프롬프트 하나가 어떤 순서로 도는지
+
+## 기준선을 다시 재려면
+
+```bash
+python3 tools/quality_baseline.py
+```
+
+리팩터링 전후를 비교하려면 **바꾸기 전에** 이걸 돌려서 숫자를 남겨둔다.
+`CURRENT.md`의 "품질 기준선" 표가 2026-08-22 기준값이다.
+
+## 이 문서를 언제 고치나
+
+- 구조를 실제로 바꿨을 때 (`CURRENT.md`)
+- 패턴 하나를 채택하거나 기각했을 때 — **기각도 적는다.** 왜 안 골랐는지가 다음에
+ 같은 논의를 반복하지 않게 한다 (`PATTERNS.md`)
diff --git a/docs/architecture/REFACTOR-PLAN.md b/docs/architecture/REFACTOR-PLAN.md
new file mode 100644
index 0000000..a77f361
--- /dev/null
+++ b/docs/architecture/REFACTOR-PLAN.md
@@ -0,0 +1,167 @@
+# 리팩터링 준비
+
+**아직 아무것도 시작하지 않았다.** 이 문서는 "무엇을 어떤 순서로 할 것인가"에 대한
+제안이고, 착수는 같이 정한다.
+
+순서를 정할 때 쓴 기준 하나: **위험이 낮은 것부터.** 여기서 위험은 "잘못됐을 때
+알아차리기 어려운 정도"다. 테스트를 쓰는 일은 위험이 0이고, 조립을 바꾸는 일은
+높다. 그리고 앞 단계가 뒤 단계의 안전망이 된다.
+
+```mermaid
+flowchart LR
+ S0["0단계
기준선
완료"] --> S1["1단계
테스트만 붙이기
위험 0"]
+ S1 --> S2["2단계
로직 추출
위험 낮음"]
+ S2 --> S3["3단계
중복 제거
위험 중간"]
+ S3 --> S4["4단계
조립 정리
위험 높음"]
+ S4 --> S5["5단계
다시 판단"]
+```
+
+---
+
+## 0단계 — 기준선 (완료)
+
+`tools/quality_baseline.py`로 2026-08-22 값을 찍었다. 숫자는
+[`CURRENT.md`](CURRENT.md)에 있다. **바꾸기 전 값이 있어야 "좋아졌다"를 말할 수 있다.**
+
+같이 만든 `tools/artifact_check.sh`는 계약 검증용이다. 두 스크립트를 CI에 물릴지는
+1단계와 같이 정한다.
+
+---
+
+## 1단계 — 옮기지 않고 테스트만 붙인다 (위험 0)
+
+**이미 `Shared/`에 있는데 테스트가 없는 것들이다.** 코드를 한 줄도 안 옮기므로
+동작이 바뀔 수 없다. 그런데 이 아홉 개가 앱에서 제일 자주 불린다.
+
+| # | 대상 | 줄 | 왜 먼저인가 |
+|---|---|---:|---|
+| 1 | `KoreanHoliday` | 199 | 음력 변환, 대체공휴일 4규칙, 연휴 겹침 회피. **가장 큰 무커버리지 덩어리**이고 주석이 2025년 어린이날 사례를 콕 집어 설명하는데 그 케이스를 지키는 테스트가 없다 |
+| 2 | `TodoItem+Recurrence` | 96 | `TodoSnapshot` 쪽 복사본만 테스트가 있다. **앱·알림·위젯이 실제로 부르는 건 모델 쪽**인데 한 줄도 안 덮였다. 두 구현이 같아야 한다는 주석까지 있다 |
+| 3 | `CalendarSnapshotBuilder` | — | 앱과 위젯이 같은 달력을 그리게 하는 핵심. 부품은 덮였는데 엮는 부분이 안 덮였다 |
+| 4 | `CalendarSelection` | 25 | 순수 함수 넷. "숨긴 쪽을 저장한다"는 반전 규칙이 깨지면 **데이터가 통째로 사라진 것처럼 보인다.** 여섯 화면이 쓴다 |
+| 5 | `MonthPageMetrics` | 68 | 이미 순수 struct인데 미테스트. `barCapacity` 경계는 맥 창 최소 크기의 근거이기도 하다 |
+| 6 | `AnalyticsEvent`의 이름·파라미터 | 90 | `notification_open`이 Firebase 예약어라 이름을 바꿨다는 주석이 있는데 그 이름을 고정하는 테스트가 없다 |
+| 7 | `WidgetDeepLink` | 14 | URL 왕복. 5줄짜리인데 테스트 0 |
+| 8 | `CalendarMonth`의 `floorDiv` | — | 음수 처리를 주석이 일부러 설명하는데 테스트가 없다 |
+| 9 | `Date+Korean`의 `dDayLabel`·`koreanTime` | — | D-DAY/D-n 경계. `koreanTime`은 `TimeExpressionParser`와 같은 로직의 두 번째 복사본이라 **둘이 같은지**도 같이 본다 |
+
+**끝났다고 판정하는 기준**: 아홉 개 전부에 테스트가 붙고, `MonthLayoutTests`처럼
+이름에 증상이 적혀 있고, 전체가 통과한다. 예상 테스트 수 +60~90건.
+
+**PR 단위**: 대상 하나에 PR 하나. 되돌릴 일이 없지만 리뷰가 쉬워진다.
+
+**위임**: `test-author` 에이전트 후보다. 규칙이 명확하고 반복적이다.
+
+---
+
+## 2단계 — 뷰 안의 로직을 `Shared/`로 (위험 낮음)
+
+여기부터 코드가 움직인다. **한 번에 하나씩, PR 하나에 하나씩.**
+
+값이 큰 순서다. "값"은 (깨졌을 때의 손해) × (지금 검증이 없는 정도)로 봤다.
+
+| # | 무엇 | 지금 위치 | 옮길 곳(제안) | 테스트로 덮을 것 |
+|---|---|---|---|---|
+| 1 | 오전/오후 모호성 해소 정책 (56줄) | `QuickAddView:481-536` | `Shared/TimeSuggestionPolicy` | "11시~1시는 정오를 넘긴다", "6시~7시는 후보 셋" |
+| 2 | 일정 확정 규칙 (반복×종일×종료) | `EventScheduleSheet:181-214` | `Shared/SchedulePlan` | 네 조합의 시작·종료 확정. 틀리면 데이터가 조용히 망가진다 |
+| 3 | 목록 파생 규칙 넷 | `TodayTodoScreen:64-157` | `Shared/TodoListRules` | "반복은 지난 것이 아니다", "지난 디데이 제외", nil 날짜 4-case 비교 |
+| 4 | 정렬 비교자 **통합** | `TodayTodoScreen:121` + `DayTodosContentView:286` | `Shared/TodoOrdering` | **두 화면이 같은 순서를 낸다** (지금 2단 vs 3단으로 갈려 있다) |
+| 5 | 일정 라벨 정책 **통합** | `TodoRow:288` + `TodoDetailSheet:141` | `Shared/ScheduleLabel` | 멀티데이·시간만·날짜포함 분기 |
+| 6 | 막대 넘침 계산 | `WeekBarsView:27-64` | `Shared/MonthEventLayout`에 합류 | 7열 카운팅, `+N` 경계 |
+| 7 | 타임라인 픽셀 환산 | `DayTimelineView:201-248` | `Shared/TimelineLayout`에 합류 | 같은 공식 세 벌이 일치하는지 |
+| 8 | 동기화 상태 → 문구·심볼 | `SettingsScreen:382-413` | `Shared/CloudSyncPresentation` | 5-state × 3출력 전수 |
+| 9 | 칩 라벨 ("전체"/이름/"N개") | `CalendarScreen:231-242` | `Shared/CalendarChip` | "전체 == 개수 같을 때" 경계 |
+| 10 | 알림 리드타임 라벨 | `CategoryEditorSheet:148` | `Shared/LeadTimeLabel` | 0/59/60/120 경계 |
+| 11 | 디데이 라벨 | `TodayTodoScreen:628` | 1단계 9번과 합침 | — |
+| 12 | 터치 판정(이동 임계·풀다운) | `CellTouchBridge:97-133` | `Shared/TouchDecision` | 주석에 "실기기에서만 재현"이라 적힌 버그 이력이 있다 |
+| 13 | 저장 시 필드 전파 규칙 | `QuickAddView:592-659` | `Shared/TodoDraft` | `startDate == nil`이 일곱 필드에 퍼지는 규칙 |
+
+**하는 방법** (13번 다 같다)
+
+1. 순수 함수/값 타입으로 뽑아 `Shared/`에 새 파일로 만든다
+2. **테스트를 먼저 쓴다.** 지금 동작을 그대로 고정하는 테스트다
+3. 뷰가 그것을 부르게 바꾼다
+4. 빌드 + 테스트 + `quality_baseline.py`로 "뷰 안의 로직" 수치가 내려갔는지 확인
+
+**끝났다고 판정하는 기준**: 스크립트의 "뷰 안의 비자명 로직"이 71 → 30 이하.
+
+**주의**: 4번과 5번은 **두 화면의 동작을 하나로 맞추는 것**이라 사용자에게 보이는
+변화가 있을 수 있다. 이 둘은 🔴(반드시 확인)이고 검증 카드가 필요하다.
+
+---
+
+## 3단계 — 중복 걷어내기 (위험 중간)
+
+조사가 찾은 복사본 20건 중, 2단계에서 자동으로 사라지는 것을 빼고 남는 것들이다.
+
+| 무엇 | 몇 벌 | 어떻게 |
+|---|---|---|
+| 목록 재인덱싱 `move` | 2 | `Shared`로 하나 |
+| `showsCalendarTag` | 2 | 한 글자도 안 다르다. 하나로 |
+| `dismissKeyboard` | 3 | 뷰 확장 하나로 |
+| 캘린더 가시성 필터 | 4 | 1단계 4번(`CalendarSelection`)으로 모은다 |
+| 권한 토글 바인딩 | 3 | 공통 컴포넌트 하나 (`PermissionToggle`) |
+| 에디터 시트 쌍 | 2 | 카테고리/캘린더가 알림 섹션 빼고 같다 |
+| 목록 화면 쌍 | 2 | 위와 같은 이유 |
+| `weekendKind(column:)` | 2 | `Shared`로 |
+| 리스트 행 3종 세트 | 8 | 뷰 확장 하나 |
+
+**주의**: 에디터 시트와 목록 화면을 합치는 것은 **추상화를 하나 만드는 일**이라
+성격이 다르다. 잘못 합치면 나중에 갈라질 때 더 비싸다. 이 둘은 마지막에 하거나 안
+해도 된다.
+
+---
+
+## 4단계 — 조립 정리 (위험 높음)
+
+`RootTabView`가 지고 있는 13가지를 쪼갠다. 여기부터는 앱 전체가 걸리므로 **1~3단계가
+끝나 테스트가 충분히 쌓인 뒤에** 한다.
+
+쪼갤 후보는 이렇다.
+
+- 시딩·iCloud 중복 병합 → `AppBootstrap`
+- 알림 재예약 키 + 라이브 액티비티 핑거프린트 → **문자열 조립을 그만두고** 모델
+ 변경 감지로 바꿀지 검토 (지금은 필드를 손으로 나열한다)
+- 튜토리얼 탭 강제 이동 → 코디네이터 쪽으로
+- 권한 요청 순서 → 이미 `StartupPermissionRequest`가 있다. 거기로 모은다
+
+**같이 볼 것**: 싱글턴 11개를 주입으로 모으는 일(`PATTERNS.md` B3)과 겹친다.
+프리뷰가 살아나는 것이 부수 효과인데, 그게 생각보다 크다 — 지금은 화면 하나를
+따로 띄워볼 수 없다.
+
+---
+
+## 5단계 — 그때 다시 판단
+
+1~4단계가 끝나면 남은 문제가 지금과 다르게 보일 것이다. 그 시점에 정할 것들이다.
+
+- **호스트 앱을 붙일 것인가** (UI 테스트·제스처 검증). 지금 R7이 "UI 테스트도
+ 만든다"고 적어놓고 못 하고 있어서 **어느 쪽이든 정해야 한다**
+- **SPM 모듈로 쪼갤 것인가** (컴파일러가 의존 방향을 강제)
+- **화면 상태를 struct 하나로 모을 것인가** (`PATTERNS.md` B5)
+
+---
+
+## 규칙 — 이 리팩터링을 하는 동안
+
+1. **PR 하나에 대상 하나.** 되돌리기가 전부다.
+2. **테스트를 먼저 쓰고 옮긴다.** 옮기고 나서 쓰면 옮기다 생긴 버그를 테스트가
+ 그대로 고정한다.
+3. **동작을 바꾸지 않는다.** 바꿔야 할 것을 발견하면 별도 항목으로 적고 따로 한다.
+ 리팩터링 PR에 기능 변경이 섞이면 사고가 나도 원인을 못 가린다.
+4. **매 PR에 `quality_baseline.py` 결과를 붙인다.** 숫자가 안 내려가면 그 PR은
+ 리팩터링이 아니라 이사다.
+5. **`docs/review-criteria.md`의 등급을 PR마다 붙인다.** 4번(정렬)과 5번(라벨)처럼
+ 사용자에게 보이는 변화가 있는 것은 🔴이다.
+
+## 규모 감
+
+| 단계 | 대상 | 예상 | 위험 |
+|---|---|---|---|
+| 1 | 9개에 테스트 | 2~3일 (위임 가능) | 없음 |
+| 2 | 13개 추출 | 4~6일 | 낮음. 둘만 🔴 |
+| 3 | 중복 9종 | 2~3일 | 중간 |
+| 4 | 조립 정리 | 3~5일 | 높음 |
+
+**한 번에 다 하지 않는다.** 1단계만 해도 테스트가 두 배가 되고, 그것만으로 다음
+단계가 훨씬 안전해진다.
diff --git a/docs/harness/README.md b/docs/harness/README.md
index b9592e8..ae384d1 100644
--- a/docs/harness/README.md
+++ b/docs/harness/README.md
@@ -102,7 +102,7 @@ CLAUDE.md 규칙 본문 (AI가 매 세션 읽는다)
| R10 읽는 범위를 좁힌다 | v1.1.0 보고서 | M15 |
그리고 그 위에 **최종 지표 M12 프롬프트당 토큰**이 있다. 규칙 하나가 담당하지 않고
-열 개가 전부 여기로 모인다 — 다른 지표가 다 좋아져도 M12가 안 내려가면 그 버전의
+열한 개가 전부 여기로 모인다 — 다른 지표가 다 좋아져도 M12가 안 내려가면 그 버전의
하네스는 실패로 적는다.
**v1.2.0에서 처음 채점됐다.** R2·R7·R10이 효과, R9가 무효, 나머지 여섯은 표본
diff --git a/docs/harness/rules.md b/docs/harness/rules.md
index 329b972..1e585fe 100644
--- a/docs/harness/rules.md
+++ b/docs/harness/rules.md
@@ -74,6 +74,27 @@ v1.4.0에서도 표본이 안 차면 그때 판단한다.
| M13 | 상위 모델 비중 | 맥락 | 가장 비싼 모델이 어시스턴트 턴의 몇 %를 먹었나 |
| M14 | 위임 비중 | ↑ | 서브에이전트(사이드체인)가 돈 턴의 비율 |
| M15 | 전체 읽기 비율 | ↓ | 범위(`offset`/`limit`)를 안 준 `Read`가 전체 `Read` 중 몇 % |
+| M16 | 임시 코드 잔존 | ↓ 0 | 커밋에 남은 `// TEMP:` 표식 수 (R11) |
+| M17 | 같은 증상 재시도 | ↓ | 한 증상에 대해 고치고 다시 실패한 최대 횟수 (R1) |
+| M18 | 규칙↔권한 불일치 | ↓ 0 | 규칙이 금지한 것이 `allow`에 있거나, 규칙이 요구하는 것이 `allow`에 없는 항목 수 |
+
+### 아직 스크립트에 없는 것 (2026-08-22)
+
+M16·M17·M18은 v1.3.0 보고서에서 신설했고 **아직 `harness_report.py`에 구현이
+없다.** M16은 `tools/quality_baseline.py`가 세고 있고, 나머지 둘은 다음 구간에
+붙인다. 구현 전까지는 정성적으로 적는다.
+
+**고쳐야 하는 정의 넷**도 같이 적어둔다. v1.3.0에서 드러난 것들이다.
+
+| 지표 | 지금 | 어떻게 고칠 것인가 |
+|---|---|---|
+| M15 | `Read` 도구만 센다 | Bash의 파일 읽기도 센다. `sed -n 'a,bp'`·`head -n`·`grep -n`은 범위 있음, `cat`은 전체 |
+| M6 | 머지 커밋 제목을 본다 | `git log --no-merges`로 브랜치 커밋을 본다 |
+| M11 | MCP 도구만 센다 | Bash의 `xcrun simctl`도 센다. 예외분(PR 스크린샷)은 따로 나눈다 |
+| M4 | 사용자 발화만 센다 | AI가 측정 없이 감각어를 쓴 것을 센다 — 규칙이 금지한 것이 그쪽이다 |
+
+정의를 고치면 **스키마 번호를 올리고 이전 버전 전체를 다시 돌려** 원장을 재생성한다.
+한 버전만 새 정의로 재면 그 순간 추세가 거짓말이 된다.
M5·M6·M9·M13은 담당 규칙이 없는 **맥락 지표**다. 특정 규칙으로 움직이는 게 아니라
그 버전이 어떤 성격이었는지 읽는 데 쓴다.
@@ -127,6 +148,53 @@ M12에서 캐시 읽기를 빼지 않는 것이 그 이유다. 빼면 "컨텍스
## 원장
+### v1.3.0 채점 결과 (2026-08-22)
+
+**프롬프트 31개.** 여전히 50개 미만이라 비율 지표는 흔들린다. 다만 v1.2.0과 같은
+방향으로 움직인 것들은 두 구간 연속이므로 "효과"로 올렸다.
+
+| 규칙 | 담당 | v1.2.0 | v1.3.0 | 판정 |
+|---|---|---:|---:|---|
+| R1 | M3 · M1 | 15.9% · 17.1% | **4.8% · 12.9%** | **효과** (2회차) |
+| R2 | M11 · M10 | 26 · 108 | 18 · 112 | **효과** — 단 M11은 과소 계수(아래) |
+| R3 | M1 · 통과율 | 17.1% | 12.9% · 카드 9건 | **효과** |
+| R4 | M4 | 14.3% | 0.0% | **미확인** — 지표가 규칙과 다른 것을 본다(아래) |
+| R5 | M2 | 2.9 | **1.67** | **부분 효과** — 목표 1.0 미달 |
+| R6 | M7 · M8 | 0 · 0 | 0 · 0 | **효과** |
+| R7 | M10 | 108 | 112 | **부분 효과** — 못 덮는 종류가 세 번 나왔다 |
+| R8 | M13 | 100% | 100% | **미확인** — 3회차. 모델이 고정이다 |
+| R9 | M14 | 0% | 0% | **실행 불가** — 무효가 아니다(아래) |
+| R10 | M15 | 33.3% | 100.0% | **측정 불능** — 정의가 무너졌다(아래) |
+
+**최종 지표 M12: 15,026,683 → 6,743,875 (55% 감소).** 두 구간 연속 절반이 됐다.
+직접 원인은 턴 수(1,259 → 563)이고, 그건 읽기·편집을 Bash 한 번에 묶은 결과다.
+
+이번 채점에서 **지표 쪽에 문제가 셋 드러났다.**
+
+1. **M15·M6가 측정 불능이 됐다.** M15는 `Read` 도구만 세는데 이번 구간의 읽기는
+ 대부분 `sed -n`·`grep`으로 나갔다. M6는 스쿼시 머지라 커밋 제목에 접두사가 없다.
+ **둘 다 "규칙을 안 지켰다"가 아니라 "여기서는 안 보인다"는 뜻이다.** 정의를 고치기
+ 전까지 두 지표의 값은 판정에 쓰지 않는다.
+2. **M11이 과소 계수다.** MCP 도구 호출만 세고 Bash의 `xcrun simctl`은 안 센다.
+ 그리고 권한 파일이 그 Bash 경로를 자동 승인으로 열어두고 있었다 — 규칙은 금지,
+ 권한은 허용이었다.
+3. **M4는 사용자 발화만 센다.** R4가 금지한 것은 AI가 측정 없이 감각어를 쓰는
+ 것인데 지표는 사용자가 감각어를 쓴 비율을 본다. 서로 다른 것을 보고 있다.
+
+**규칙 변경**: R1 확장(증상이 그대로면 경로부터 확인), R2 단서(예외를 썼으면 얻은
+것을 적는다), R9 개정(조사류는 묻지 않고 위임 — "사용자 요청 시에만"이 규칙의 실행을
+막고 있었다), **R11 신설**(진단용 코드에 표식을 달고 커밋 전에 걷는다).
+
+### R11 (2026-08-22 도입)
+
+| 규칙 | 담당 지표 | 기준 | 목표 | 판정 |
+|---|---|---|---|---|
+| R11 진단용 코드는 표식 달고 걷는다 | M16 임시 코드 잔존 | (미측정) | 0 | — |
+
+넣은 이유: v1.3.0에서 탐침 파일 하나와 추적 로그 두 줄이 커밋될 뻔했고, 사용자가
+"불필요한 코드는 제거하고"라고 말한 뒤에야 걷었다. PR 템플릿에 체크박스가 있었지만
+스스로 체크하는 것이라 작동하지 않았다. `tools/quality_baseline.py`가 표식을 센다.
+
### v1.2.0 채점 결과 (2026-08-19)
**프롬프트 35개.** 판정 기준의 "50개 미만이면 미확인"에 걸려 비율 지표 다섯을
diff --git a/docs/review-criteria.md b/docs/review-criteria.md
new file mode 100644
index 0000000..0121f7e
--- /dev/null
+++ b/docs/review-criteria.md
@@ -0,0 +1,116 @@
+# 무엇을 사람이 봐야 하는가
+
+혼자 만드는 앱에서 가장 비싼 자원은 **사람이 코드를 읽는 시간**이다. 전부 읽으면
+AI를 쓰는 의미가 없고, 안 읽으면 조용히 망가진다. 그 사이 어디에 선을 그을지에
+대한 기준이다.
+
+먼저 용어를 나눈다. 이 문서에서 "확인"은 두 가지 다른 일을 뜻한다.
+
+| | 무엇을 하나 | 비용 |
+|---|---|---|
+| **코드 확인** | diff를 읽고 판단한다 | 분 단위. 집중이 필요하다 |
+| **동작 확인** | 앱을 켜서 만져본다 | 분~시간. 기기·계정·상태가 필요할 때가 있다 |
+
+둘은 따로 정한다. 코드는 안 봐도 되는데 동작은 봐야 하는 것이 있고(예: 위젯 렌더),
+그 반대도 있다(예: 삭제 로직 리팩터링).
+
+## 판단 기준 — 두 축
+
+```mermaid
+flowchart TD
+ S([이 변경을 사람이 봐야 하나]) --> M{기계가 틀렸다고
말해줄 수 있나}
+ M -->|예| R{틀렸을 때
되돌리기 쉬운가}
+ M -->|아니오| R2{틀렸을 때
되돌리기 쉬운가}
+
+ R -->|쉽다| G["🟢 안 봐도 된다
CI만 초록이면 통과"]
+ R -->|어렵다| Y["🟡 훑어본다
diff 제목과 요약만"]
+ R2 -->|쉽다| Y
+ R2 -->|어렵다| RD["🔴 반드시 본다
diff를 읽고, 앱을 켠다"]
+```
+
+**"기계가 말해줄 수 있나"** — 테스트·빌드·CI·스크립트 중 무엇이든 실패로 알려주면
+예다. 사람이 안 보면 아무도 모르는 것이면 아니오다.
+
+**"되돌리기 쉬운가"** — 커밋을 되돌리면 끝나면 쉽다. 데이터가 이미 바뀌었거나,
+사용자에게 나갔거나, 스토어에 올라갔으면 어렵다.
+
+## 이 저장소에 적용하면
+
+### 🟢 안 봐도 되는 것
+
+CI가 초록이면 그대로 통과시켜도 되는 것들이다.
+
+| 무엇 | 왜 |
+|---|---|
+| `Mosco/Shared/`의 순수 로직 **중 테스트가 붙은 변경** | 테스트가 판정한다. 112건이 이 자리를 지킨다 |
+| 테스트 코드 자체의 추가 | 틀리면 빨갛다. 안 틀리면 안전망이 는 것뿐 |
+| 문서 오타·서식·링크 | 되돌리기가 한 줄 |
+| 주석 추가 | 동작이 안 바뀐다 |
+| 기계적 이름 바꾸기 (컴파일러가 전수 확인) | 빌드가 판정한다 |
+| 죽은 코드 삭제 **중 `deadcode_audit.py`가 지목한 것** | 도구가 근거를 준다 |
+
+### 🟡 훑어보는 것
+
+diff 전체를 읽을 필요는 없고, **무엇을 왜 바꿨는지 한 문단과 파일 목록**만 보면
+되는 것들이다. 이상하면 그때 파고든다.
+
+| 무엇 | 무엇을 볼 것인가 |
+|---|---|
+| 화면 안쪽 레이아웃·간격·색 | 규범을 지켰다고 적혀 있는지. 새 색·새 반경을 만들지 않았는지 |
+| 새 순수 로직 + 그 테스트 | 테스트 이름이 증상을 말하는지 |
+| 리팩터링 (동작 불변 주장) | **무엇이 안 바뀐다고 주장하는지**. 그 주장의 근거가 테스트인지 |
+| CI·스크립트 변경 | 그 변경이 실제로 돌았는지 (CI 로그) |
+| 위젯 코드 | 앱과 공유하는 타입을 건드렸는지 |
+
+### 🔴 반드시 보는 것
+
+여기는 예외 없이 사람이 본다. **기계가 판정 못 하고, 틀리면 되돌리기 어렵다.**
+
+| 무엇 | 왜 | 코드 | 동작 |
+|---|---|---|---|
+| **삭제·초기화 흐름** | 되돌릴 수 없다. `TodoCategory.delete`, `resetAllData`, 스와이프/메뉴 삭제 | ✅ | ✅ |
+| **SwiftData 스키마 변경** | 마이그레이션. 기존 사용자 데이터가 걸려 있다 | ✅ | ✅ |
+| **entitlements · `Info.plist` · 빌드 설정** | 빌드도 CI도 통과하면서 틀릴 수 있다. v1.3.0에 두 번 물렸다 | ✅ | ✅ |
+| **권한을 묻는 순서·시점** | 한 번 거부되면 앱에서 다시 못 묻는다 | ✅ | ✅ |
+| **사용자에게 보이는 문구** | 규범 11개가 걸려 있고, 나가면 못 주워담는다 | ✅ | — |
+| **기존 동작을 없애는 변경** | 쓰던 사람이 "어? 왜 안 되지"를 겪는다. 스와이프 삭제 제거가 그랬다 | ✅ | ✅ |
+| **동기화·앱 그룹 경계** | 앱·위젯·잠금화면 세 프로세스가 걸려 있다 | ✅ | ✅ |
+| **릴리스 산출물** (버전, 릴리스 노트, 스토어 제출물) | 외부로 나간다 | ✅ | — |
+| **조건부 컴파일이 들어간 변경** | 한쪽 플랫폼에서만 도는 코드는 다른 쪽에서 안 도는 것을 아무도 안 알려준다 | ✅ | ✅ |
+
+### 판정이 갈리면
+
+**🔴로 올린다.** 이 프로젝트에서 지금까지 손해를 본 쪽은 항상 "안 봐도 되겠지"였다.
+
+## AI가 해야 하는 일 — 등급을 스스로 붙인다
+
+기준이 있어도 사람이 매번 분류하면 그 분류가 일이 된다. **PR을 열 때 AI가 등급을
+붙이고 근거를 적는다.**
+
+PR 본문에 이런 줄이 들어간다.
+
+```
+## 확인 등급
+
+🔴 Mosco/App/App-Mac.entitlements — 샌드박스 추가. 빌드로 판정 불가, 되돌리려면 재제출
+🟡 Mosco/App/Features/Calendar/DayTimelineView.swift — 현재 시각 줄. 규범 확인함
+🟢 Mosco/MoscoTests/TimelineLayoutTests.swift — 테스트 4건 추가
+```
+
+사용자는 🔴만 읽으면 된다. 🟡은 시간이 있을 때, 🟢은 CI를 믿는다.
+
+**등급을 낮게 매기는 것이 AI에게 이득이 되면 안 된다.** 그래서 이렇게 둔다.
+
+- 🔴을 🟡로 낮춰 적었는데 나중에 문제가 나오면, 그 사례를 `docs/TRAPS.md`에 남긴다.
+- 등급 분포는 매 릴리스 보고서에 적는다. 🔴이 0인 PR이 계속 나오면 기준이
+ 느슨해진 것이다.
+
+## 이 기준이 바뀌어야 할 때
+
+- 새 검증 수단이 생기면 🔴이 🟡로 내려온다. 예를 들어 **산출물 검사 스크립트가
+ 생기면 entitlements는 🟡가 된다** — 기계가 말해주기 시작하니까.
+- 반대로 사고가 나면 올라간다.
+
+즉 이 표는 고정된 규범이 아니라 **지금 우리가 무엇을 기계에 맡길 수 있는지에 대한
+현재 상태**다. `docs/verification.md`에 적은 도구들이 하나씩 생길 때마다 이 표를
+같이 고친다.
diff --git a/docs/verification.md b/docs/verification.md
new file mode 100644
index 0000000..10bbed2
--- /dev/null
+++ b/docs/verification.md
@@ -0,0 +1,193 @@
+# 검증 체계
+
+무엇을, 언제, 누가 판정하는가. 그리고 **테스트 코드만으로는 왜 부족한가.**
+
+이 문서는 2026-08-22에 나온 질문에서 시작했다. 원문 그대로 옮긴다.
+
+> 검증에 관해서는 지금 내 생각으로 크게 2가지로 나눌 수 있다고 생각하거든,
+> 기능 그 자체에 대한 검증, 지속 가능한 코드로 작성하는지 코드 퀄리티에 대한 검증
+> 이 2가지가 내가 생각하는 검증이야
+
+## 먼저, 이 두 분류에 대한 평가
+
+**뼈대는 맞다.** "동작하는가"와 "계속 고칠 수 있는가"는 실제로 성질이 다른 문제이고,
+전자는 사용자가, 후자는 미래의 나 자신이 손해를 본다. 나눠서 보는 게 맞다.
+
+다만 **이 프로젝트의 실제 사고 기록을 보면 두 칸으로는 안 들어가는 것이 반복해서
+나온다.** v1.3.0 한 버전에서 잡힌 버그 넷을 놓고 보자.
+
+| 버그 | 기능 검증인가 | 품질 검증인가 |
+|---|---|---|
+| 스와이프 삭제가 안 되던 것 | 애매 — 기능은 멀쩡했고 **화면이 다른 모드**였다 | 아니다 |
+| KVS entitlement 누락 | 아니다 — 코드는 정상, **빌드 산출물의 서명**이 문제 | 아니다 |
+| 그 entitlement가 시뮬레이터 실행을 막던 것 | 아니다 — **환경에 따라** 다르게 동작 | 아니다 |
+| 맥에서 날씨가 안 불리던 것 | 반쯤 — iOS는 정상, **맥에서만** 호출 경로가 안 열림 | 아니다 |
+
+**넷 중 어느 것도 두 칸에 깔끔히 안 들어간다.** 그래서 칸을 넷으로 늘리는 것을
+제안한다.
+
+## 제안하는 분류 — 네 종류
+
+### 1. 기능 검증 — "의도한 대로 도는가"
+
+입력을 넣으면 기대한 출력이 나오는가. 날짜 계산, 반복 전개, 시각 파싱, 정렬 규칙,
+권한 진리표 같은 것들.
+
+**이게 테스트 코드가 가장 잘하는 영역이고, 지금 112건이 여기 있다.**
+
+### 2. 계약 검증 — "약속한 모양이 지켜지는가"
+
+코드가 아니라 **코드 바깥의 약속**이다. 이 프로젝트에는 이런 것들이 있다.
+
+- entitlements (App Group, iCloud, KVS, 샌드박스)
+- `Info.plist` 키 (위치·알림 사용 설명, 수출 규정)
+- 앱과 위젯이 같은 버전인가 (다르면 업로드가 거부된다)
+- 프로세스 간 `Codable` 계약 (`TodoActivityAttributes`)
+- App Group 컨테이너 경로가 앱·위젯·테스트에서 같은가
+- 애널리틱스 이벤트 이름과 `PRIVACY.md`가 맞는가
+
+**컴파일도 통과하고 테스트도 통과하는데 틀릴 수 있다.** v1.3.0의 KVS 건이 정확히
+그랬다 — 실기기는 되고 시뮬레이터는 안 되고 CI는 초록이었다.
+
+### 3. 환경 검증 — "다른 데서도 같은가"
+
+같은 코드가 환경에 따라 다르게 도는 것. iOS / Mac Catalyst / 시뮬레이터 / 실기기 /
+위젯 프로세스 / 잠금화면 인텐트.
+
+조건부 컴파일(`#if targetEnvironment(macCatalyst)`)이 있는 순간 이 축이 생긴다.
+지금 이 프로젝트에는 그런 자리가 여섯 군데 있다.
+
+### 4. 품질 검증 — "계속 고칠 수 있는 모양인가"
+
+지속 가능성. 여기가 제일 애매하고, 그래서 뒤에서 따로 다룬다.
+
+## 두 번째 축 — 언제 판정이 나오는가
+
+분류만큼 중요한 것이 **판정 시점**이다. 같은 문제라도 늦게 잡을수록 비싸다.
+
+```mermaid
+flowchart LR
+ E["편집 직후
초"] --> C["커밋 전
분"]
+ C --> P["PR / CI
분~시간"]
+ P --> H["사람 확인
시간~일"]
+ H --> S["심사·출시
일~주"]
+```
+
+v1.3.0의 실제 사례를 이 축에 얹어보면 무엇이 문제인지 바로 보인다.
+
+| 문제 | 실제로 잡힌 시점 | 잡을 수 있었던 가장 이른 시점 |
+|---|---|---|
+| 스와이프 삭제 오진 | 사람 확인 (5회 왕복) | **편집 직후** — 그 코드가 도는지 로그 한 줄 |
+| KVS entitlement | 사람 확인 (실기기 로그) | **커밋 전** — 산출물 entitlement 검사 |
+| 시뮬레이터 실행 불가 | 사람 확인 (스크린샷 찍다가) | **PR/CI** — 시뮬레이터에 설치·실행해보기 |
+| 맥 날씨 미호출 | 사람 확인 | **PR/CI** — 맥에서 실행 |
+
+**넷 다 원래 잡혔어야 할 시점보다 두세 칸 늦게 잡혔다.** 그리고 늦게 잡힌 대가는
+전부 사람의 왕복으로 지불됐다.
+
+## 그래서, 테스트 코드만으로 검증할 수 있는가
+
+**아니다.** 위 표가 그 답이다. v1.3.0에서 잡힌 문제 넷 중 **테스트 코드로 잡을 수
+있었던 것은 0개**다.
+
+그렇다고 테스트가 쓸모없다는 뜻은 전혀 아니다. 테스트는 **기능 검증에서는
+독보적**이고, 지금 112건이 날짜 경계·반복 전개·시각 파싱 같은 반복해서 깨지던
+자리를 실제로 지키고 있다. 문제는 **테스트가 못 닿는 세 영역을 그동안 전부 사람에게
+떠넘겨왔다**는 것이다.
+
+검증 종류마다 맞는 수단이 다르다. 이렇게 붙이는 것을 제안한다.
+
+| 종류 | 수단 | 시점 | 지금 상태 |
+|---|---|---|---|
+| 기능 | 유닛 테스트 (Swift Testing) | 편집~커밋 | ✅ 112건 |
+| 기능 (화면 흐름) | UI 테스트 (XCUITest) | PR/CI | ❌ 호스트 앱이 없어 못 쓴다 |
+| 계약 | **산출물 검사 스크립트** | 커밋~CI | ❌ 없음 (초안 있음) |
+| 환경 | **플랫폼 매트릭스 빌드** | CI | ⚠️ 절반 — Catalyst 빌드는 추가됐지만 실행은 안 해본다 |
+| 환경 (실행) | 시뮬레이터 설치·실행 스모크 | CI | ❌ 없음 |
+| 품질 (셀 수 있는 것) | **정적 검사 스크립트** | 커밋~CI | ⚠️ `deadcode_audit.py`가 있지만 어디에도 안 물려 있다 |
+| 품질 (판단이 필요한 것) | 규범 문서 + 리뷰 | PR | ⚠️ 규범은 있는데 대조하는 절차가 없다 |
+| 최종 | 사람 검증 카드 | 사람 | ✅ `/handoff` |
+
+## 품질 검증을 어떻게 셀 것인가
+
+여기가 제일 어렵다. "지속 가능한 코드"는 느낌이라 그대로 두면 판정이 매번 달라진다.
+**셀 수 있는 것과 판단이 필요한 것을 나누는 것**이 유일한 방법이다.
+
+### 셀 수 있는 것 — 기계가 판정
+
+이번 전수 조사에서 실제로 세어본 것들이다. 즉, 스크립트로 만들 수 있다는 뜻이다.
+
+| 무엇 | 지금 값 | 왜 이게 품질인가 |
+|---|---|---|
+| 뷰 안에 있는 비자명 로직 | **50곳 이상** | 테스트를 쓸 수 없는 코드의 양 그 자체 |
+| 한 파일이 지는 책임 수 | 최대 11개 (`QuickAddView` 679줄) | 고칠 때 읽어야 하는 양 |
+| 같은 로직의 복사본 | **20건** (라벨 정책, 정렬 비교자, 재인덱싱…) | 한쪽만 고쳐서 갈라진 사례가 이미 있다 |
+| Feature 간 순환 의존 | 1쌍 (Calendar ↔ Settings) | 한쪽을 못 떼어낸다 |
+| 순수 로직 중 테스트 없는 것 | 9개 (공휴일 계산, 반복 판정 모델 쪽…) | 덮을 수 있는데 안 덮은 것 |
+| 토큰 우회 | 폰트 33곳, 여백 42곳 | 규범이 실제로 안 지켜지는 양 |
+| 임시 코드 잔존 | (표식 도입 후 측정) | 커밋에 섞여 들어간 디버그 흔적 |
+
+**이 일곱 개를 매 릴리스에 세면 "품질이 좋아지고 있는가"에 숫자로 답할 수 있다.**
+지금은 답할 수 없다.
+
+### 판단이 필요한 것 — 사람 또는 규범 대조
+
+- 이 추상화가 과한가, 모자란가
+- 이 이름이 무엇인지 말하는가
+- 주석이 "왜"를 적었는가, "무엇"을 되풀이했는가
+- 이 결정이 나중에 되돌릴 수 있는 크기인가
+
+이건 셀 수 없다. 대신 **되돌린 기록을 남기는 것**으로 대신해왔고
+(`DesignSystem/README.md`, `docs/TRAPS.md`) 그 방식은 실제로 작동했다 — 규범 드리프트
+지표(M8)가 두 버전 연속 0이다.
+
+## 일관된 품질을 만드는 장치
+
+질문의 나머지 절반은 "AI가 일관된 품질로 코드를 작성하게 하는 검증 시스템"이었다.
+이 프로젝트의 지난 네 버전이 알려준 것은 **문서보다 강제 장치가 세다**는 것이다.
+
+| 층 | 예 | 세기 | 잊히나 |
+|---|---|---|---|
+| 문서에 적힌 규칙 | `CLAUDE.md` R1~R10 | 약 | 잊힌다. 같은 요청이 세 번 나온 적 있다 |
+| 절차 스킬 | `/intake` `/handoff` | 중 | 부를 때만 |
+| 템플릿 칸 | PR 템플릿 다섯 칸 | 중 | 빈칸이 눈에 띈다 |
+| 기계 검사 | CI, 테스트, 스크립트 | 강 | 안 잊힌다 |
+| **권한 설정** | `.claude/settings*.json` | 최강 | 안 잊힌다. 우회도 못 한다 |
+
+**v1.3.0에서 이게 실증됐다.** 시뮬레이터를 열려고 했을 때 막은 것은 R2 문서가
+아니라 권한 파일의 `deny`였다. 그리고 그 파일을 AI가 스스로 고치려 하자 그것도
+막혔다.
+
+다만 같은 조사에서 **그 강제 장치가 반쯤 새고 있다**는 것도 드러났다. `deny`는 MCP
+도구 두 개만 막는데, `allow` 쪽에 `Bash(xcrun simctl *)`가 열려 있다. 규칙과 권한이
+서로 다른 말을 하고 있으면 강제 장치가 아니다.
+
+→ **제안**: 규칙을 하나 만들 때마다 "이걸 문서로 둘 것인가, 템플릿 칸으로 둘 것인가,
+기계 검사로 둘 것인가, 권한으로 둘 것인가"를 같이 정한다. 그리고 **규칙과 권한
+파일의 불일치를 세는 검사**를 만든다.
+
+## 다음에 만들 것 (우선순위)
+
+전부 만들자는 게 아니라, 값이 큰 순서로 적는다. 실제 착수는 같이 정한다.
+
+1. **산출물 검사 스크립트** — entitlements, plist 키, 앱·위젯 버전 일치. 한 번
+ 만들면 계약 검증 전체를 커밋 시점으로 당긴다. v1.3.0의 두 버그를 다 잡았을 것이다.
+2. **품질 지표 스크립트** — 위 일곱 개를 세는 것. 리팩터링을 시작하기 전에
+ **기준선**을 찍어둬야 나중에 좋아졌는지 말할 수 있다.
+3. **호스트 앱 결정** — UI 테스트를 쓸 것인지. 쓰려면 테스트 타깃 구성을 바꿔야
+ 하고, 그러면 CI 서명 문제가 다시 온다. 안 쓰기로 하면 R7에서 "UI 테스트도
+ 만든다"를 빼야 한다. **지금은 문서와 현실이 다르다.**
+4. **시뮬레이터 스모크** — CI에서 앱을 설치·실행해 몇 초 살아 있는지만 본다.
+ v1.3.0의 시뮬레이터 실행 불가 버그를 CI가 잡았을 것이다.
+5. **규칙↔권한 대조 검사** — 위에 적은 그것.
+
+## 정리 — 원래 질문에 대한 답
+
+- **두 분류는 뼈대로 맞다.** 다만 계약과 환경을 추가해 넷으로 나누는 것을 제안한다.
+ 이 프로젝트에서 실제로 물린 버그가 그 두 칸에 있었다.
+- **테스트 코드만으로는 안 된다.** v1.3.0에서 잡힌 문제 넷 중 테스트로 잡을 수
+ 있었던 것은 없다. 종류마다 다른 수단이 필요하다.
+- **품질 검증은 셀 수 있는 것과 판단이 필요한 것으로 다시 나눈다.** 셀 수 있는
+ 일곱 개는 이미 세어봤고 스크립트로 만들 수 있다.
+- **일관성은 문서가 아니라 층위로 만든다.** 문서 < 템플릿 < 기계 검사 < 권한.
+ 규칙마다 어느 층에 둘지 같이 정한다.
diff --git a/docs/workflow.md b/docs/workflow.md
new file mode 100644
index 0000000..748335e
--- /dev/null
+++ b/docs/workflow.md
@@ -0,0 +1,125 @@
+# 워크플로우
+
+프롬프트 하나가 들어왔을 때 무엇이 어떤 순서로 도는지. **지금 실제로 도는 것**을
+그린 것이고, 앞으로 이 순서가 바뀔 때마다 여기를 같이 고친다.
+
+각 단계에 근거 문서를 붙여뒀다. 근거가 없는 칸이 있으면 그건 관행이지 규칙이
+아니라는 뜻이다 — 그런 칸은 아래 "근거가 없는 칸"에 따로 모아뒀다.
+
+## 전체 그림
+
+```mermaid
+flowchart TD
+ P([프롬프트 도착]) --> C{어떤 종류인가}
+
+ C -->|여러 항목·목록| INTAKE["/intake
쪼개기 → 되묻기 → 백로그 → 순서"]
+ C -->|릴리스| REL["RELEASING.md
0~9단계"]
+ C -->|단일 작업| SIZE
+
+ INTAKE --> SIZE[작업 크기 한 줄]
+ SIZE --> BR[브랜치 생성]
+ BR --> CANON{규범·함정
영역인가}
+ CANON -->|색·문구·컴포넌트| RD1[DesignSystem/README.md 읽기]
+ CANON -->|플랫폼 함정| RD2[docs/TRAPS.md 읽기]
+ CANON -->|아니오| SURVEY
+ RD1 --> SURVEY
+ RD2 --> SURVEY
+
+ SURVEY{조사 범위} -->|좁다| NARROW[grep으로 줄 잡고
그 언저리만 읽기]
+ SURVEY -->|넓다| DELEGATE[서브에이전트에 위임]
+ NARROW --> EDIT
+ DELEGATE --> EDIT
+
+ EDIT[편집
되돌리기 쉬운 크기] --> BUILD[빌드]
+ BUILD --> TEST[테스트]
+ TEST --> APPLIED{바꿨다고 한 것이
실제로 들어갔나}
+ APPLIED -->|스크립트 편집| GREP[grep으로 되읽기]
+ APPLIED -->|동작을 문구로 약속| PATH[그 코드 경로 확인]
+ GREP --> COVER
+ PATH --> COVER
+
+ COVER{테스트로 덮이나} -->|예| WRITE[테스트 작성]
+ COVER -->|아니오| WHY[이유 적고 카드로]
+ WRITE --> SCREEN
+ WHY --> SCREEN
+
+ SCREEN{화면 확인이
필요한가} -->|예| HANDOFF["/handoff
검증 카드 4줄"]
+ SCREEN -->|아니오| MSG
+ HANDOFF --> HUMAN[[사람이 밟는다]]
+ HUMAN --> LOG[verify-log.tsv에 한 줄]
+ LOG --> MSG
+
+ MSG[커밋 메시지 제안] --> ASK{커밋 요청받았나}
+ ASK -->|아니오| END1([끝 — 항목별 상태 반환])
+ ASK -->|예| COMMIT[커밋] --> PR[PR 열기] --> CI[CI]
+ CI -->|초록| MERGE[스쿼시 머지] --> END2([끝])
+ CI -->|빨강| EDIT
+```
+
+## 단계별 근거
+
+| # | 단계 | 근거 | 없으면 |
+|---|---|---|---|
+| 1 | 목록이면 `/intake` | `CLAUDE.md` R5, `.claude/skills/intake/SKILL.md` | 조용히 일부만 처리되고 다음 프롬프트가 "아직 안 됐는데"가 된다 |
+| 2 | 작업 크기 한 줄 | `CLAUDE.md` R8 | 사용자가 모델을 내릴 판단 재료가 없다 |
+| 3 | 브랜치 생성 | `CONTRIBUTING.md` 브랜치 전략 | `main`이 더러워진다. 예외 없음 |
+| 4 | 규범·함정 읽기 | `CLAUDE.md` R6, `DesignSystem/README.md`, `docs/TRAPS.md` | 되돌린 시도를 다시 제안한다 |
+| 5 | 읽는 범위 좁히기 / 넓으면 위임 | `CLAUDE.md` R10 · R9 | 컨텍스트가 부풀고 그 뒤 모든 턴이 비싸진다 |
+| 6 | 편집 | `CLAUDE.md` 일하는 방식 | 되돌리기 어려운 덩어리가 된다 |
+| 7 | 빌드 → 테스트 | `CLAUDE.md` R1 | 컴파일 에러를 사용자가 발견한다 |
+| 8 | 반영 확인 | `CLAUDE.md` R1 (2026-08-19 추가) | 빌드는 통과했는데 의도한 변경이 안 들어가 있다 |
+| 9 | 테스트 남기기 | `CLAUDE.md` R7 | 다음에 깨졌을 때 무엇이 깨졌는지 모른다 |
+| 10 | 화면이면 `/handoff` | `CLAUDE.md` R2 · R3, `.claude/skills/handoff/SKILL.md` | "확인해줘"가 되고 확인이 대충 된다 |
+| 11 | 검증 결과 기록 | `.claude/skills/handoff/SKILL.md` | R3의 통과율 데이터가 안 쌓인다 |
+| 12 | 커밋은 요청받을 때만 | `CLAUDE.md` 일하는 방식 | 사용자가 안 본 것이 이력에 박힌다 |
+| 13 | PR 템플릿 다섯 칸 | `.github/pull_request_template.md` | R1·R9의 기록 자리가 사라진다 |
+| 14 | CI | `.github/workflows/ci.yml` | 맥·위젯이 깨진 채로 나간다 |
+
+## 검증 사다리
+
+워크플로우 안에서 "확인했다"고 말할 수 있는 근거는 네 칸으로 나뉜다. 아래로 갈수록
+정확하고 비싸다.
+
+```mermaid
+flowchart LR
+ B["1. 빌드
컴파일된다"] --> T["2. 테스트
로직이 맞다"]
+ T --> H["3. 사람
실제로 그렇게 보인다"]
+ H --> R["4. 심사
내보내도 된다"]
+
+ B -.->|AI| B
+ T -.->|AI| T
+ H -.->|사람| H
+ R -.->|외부| R
+```
+
+1번과 2번만 AI가 할 수 있다. **그래서 AI가 "확인했다"고 쓸 수 있는 범위는 딱
+거기까지다.** 3번은 검증 카드로 넘기고, 4번은 릴리스에서 만난다.
+
+실제 버그는 대부분 3번과 4번에서 나왔다. v1.0.0 빌드 3은 WeatherKit 표기 누락으로
+심사에서 반려됐고, v1.3.0의 KVS entitlement 문제는 사용자가 붙여준 실기기 로그에서
+나왔다.
+
+## 근거가 없는 칸
+
+조사해보니 규칙은 있는데 그 규칙을 받아주는 자리가 없는 것들이 있다. 관행으로만
+도는 부분이다.
+
+| 규칙 | 없는 것 |
+|---|---|
+| R4 성능은 숫자로 | 절차·템플릿 칸이 전혀 없다. 지표(M4)는 사용자 발화만 세서 규칙이 금지한 것과 다른 것을 본다 |
+| R10 읽는 범위 | 절차도 강제 장치도 없다. 지표(M15)는 `Read` 도구만 봐서 Bash로 읽으면 안 잡힌다 |
+| R8 작업 크기 한 줄 | 말하고 끝이다. 어디에도 안 남아서 나중에 검증할 수 없다 |
+| R6 "지킨 것도 한 줄 적는다" | PR 템플릿에 받는 칸이 없다 |
+| R1 "반영 확인" | PR 템플릿의 `확인한 것`이 유일한 자리이고, 이것만 담당하는 지표가 없다 |
+| R3 "검증 통과율" | 담당 지표인데 추출 스크립트에도 원장 열에도 없다. 손으로 쓰는 TSV 7행이 전부 |
+
+## 이 문서를 언제 고치나
+
+워크플로우가 바뀔 때마다. 구체적으로는 이런 때다.
+
+- 규칙이 늘거나 줄었을 때 (`CLAUDE.md`)
+- 스킬이 생기거나 절차가 바뀌었을 때 (`.claude/skills/`)
+- CI 단계가 바뀌었을 때 (`.github/workflows/ci.yml`)
+- 서브에이전트 구성이 바뀌었을 때 (`.claude/agents/`)
+
+보고서를 쓸 때 이 문서가 최신인지 같이 확인한다.
diff --git a/tools/artifact_check.sh b/tools/artifact_check.sh
new file mode 100755
index 0000000..2ec51a9
--- /dev/null
+++ b/tools/artifact_check.sh
@@ -0,0 +1,110 @@
+#!/bin/bash
+# 빌드 산출물과 설정의 "계약"을 검사한다.
+#
+# 컴파일도 통과하고 테스트도 통과하는데 틀릴 수 있는 것들이 있다 — entitlements,
+# plist 키, 앱과 위젯의 버전 일치 같은 것. v1.3.0에서 두 번 물렸다.
+# · iCloud 키-값 저장소 entitlement가 빠져서 재설치를 못 건너왔다
+# · 그걸 고친 값이 시뮬레이터에서 앱을 아예 못 뜨게 했다 (실기기·CI는 초록)
+#
+# 근거: docs/verification.md "계약 검증"
+#
+# tools/artifact_check.sh 설정 파일만 검사 (빠르다)
+# tools/artifact_check.sh <앱경로> 서명된 산출물까지 검사
+set -uo pipefail
+cd "$(dirname "$0")/.." || exit 1
+
+FAIL=0
+ok() { printf ' ✅ %s\n' "$1"; }
+bad() { printf ' ❌ %s\n' "$1"; FAIL=1; }
+warn() { printf ' ⚠️ %s\n' "$1"; }
+
+PBX=Mosco/Mosco.xcodeproj/project.pbxproj
+APP_ENT=Mosco/App/App.entitlements
+MAC_ENT=Mosco/App/App-Mac.entitlements
+WIDGET_ENT=Mosco/MoscoWidget/MoscoWidget.entitlements
+WIDGET_MAC_ENT=Mosco/MoscoWidget/MoscoWidget-Mac.entitlements
+INFO=Mosco/App/Info.plist
+
+echo
+echo " 산출물·설정 계약 검사"
+echo " ─────────────────────────────────────"
+
+# 1. 앱과 위젯의 버전이 같은가 (다르면 업로드가 거부된다)
+echo " 버전"
+VERSIONS=$(grep -o 'MARKETING_VERSION = [^;]*' "$PBX" | awk '{print $3}' | grep -v '^1\.0$' | sort -u)
+BUILDS=$(grep -o 'CURRENT_PROJECT_VERSION = [^;]*' "$PBX" | awk '{print $3}' | grep -v '^1$' | sort -u)
+if [ "$(echo "$VERSIONS" | wc -l | tr -d ' ')" = "1" ]; then
+ ok "MARKETING_VERSION 일치 ($VERSIONS)"
+else
+ bad "MARKETING_VERSION이 갈렸다: $(echo $VERSIONS | tr '\n' ' ')"
+fi
+if [ "$(echo "$BUILDS" | wc -l | tr -d ' ')" = "1" ]; then
+ ok "CURRENT_PROJECT_VERSION 일치 ($BUILDS)"
+else
+ bad "빌드 번호가 갈렸다: $(echo $BUILDS | tr '\n' ' ')"
+fi
+
+# 2. entitlements 필수 키
+echo " entitlements"
+need_key() { # 파일, 키, 설명
+ if grep -q "$2" "$1" 2>/dev/null; then ok "$3"; else bad "$3 — $1 에 $2 없음"; fi
+}
+no_key() {
+ if grep -q "$2" "$1" 2>/dev/null; then bad "$3 — $1 에 $2 가 있으면 안 된다"; else ok "$3"; fi
+}
+need_key "$APP_ENT" "com.apple.security.application-groups" "앱: App Group"
+need_key "$APP_ENT" "ubiquity-kvstore-identifier" "앱: iCloud 키-값 저장소"
+need_key "$APP_ENT" "icloud-container-identifiers" "앱: iCloud 컨테이너"
+need_key "$APP_ENT" "com.apple.developer.weatherkit" "앱: WeatherKit"
+no_key "$APP_ENT" "com.apple.security.app-sandbox" "앱(iOS): 샌드박스 키 없음"
+need_key "$MAC_ENT" "com.apple.security.app-sandbox" "앱(맥): 샌드박스"
+need_key "$MAC_ENT" "com.apple.security.network.client" "앱(맥): 네트워크"
+need_key "$MAC_ENT" "personal-information.location" "앱(맥): 위치"
+need_key "$WIDGET_ENT" "com.apple.security.application-groups" "위젯: App Group"
+need_key "$WIDGET_MAC_ENT" "com.apple.security.app-sandbox" "위젯(맥): 샌드박스"
+
+# 3. App Group id가 코드와 같은가
+echo " App Group"
+CODE_GROUP=$(grep -o 'appGroupID = "[^"]*"' Mosco/Shared/SharedModelContainer.swift | cut -d'"' -f2)
+if grep -q "$CODE_GROUP" "$APP_ENT" && grep -q "$CODE_GROUP" "$WIDGET_ENT"; then
+ ok "코드와 entitlements가 같다 ($CODE_GROUP)"
+else
+ bad "코드의 $CODE_GROUP 가 entitlements와 안 맞는다"
+fi
+
+# 4. Info.plist 사용 설명
+echo " Info.plist"
+need_key "$INFO" "NSLocationWhenInUseUsageDescription" "위치 사용 설명"
+if grep -q "ITSAppUsesNonExemptEncryption" "$INFO"; then
+ ok "수출 규정"
+else
+ warn "수출 규정 키가 없다 — 제출할 때마다 물어본다 (docs/BACKLOG.md)"
+fi
+
+# 5. 서명된 산출물 (경로를 줬을 때만)
+if [ $# -ge 1 ]; then
+ APP="$1"
+ echo " 서명된 산출물 $APP"
+ if [ ! -d "$APP" ]; then
+ bad "그 경로에 .app이 없다"
+ else
+ ENT=$(codesign -d --entitlements :- "$APP" 2>/dev/null | tr -d '\0')
+ if [ -z "$ENT" ]; then
+ bad "서명 정보를 못 읽었다 (CODE_SIGNING_ALLOWED=NO로 빌드했나?)"
+ else
+ for KEY in application-identifier com.apple.security.application-groups; do
+ echo "$ENT" | grep -q "$KEY" && ok "산출물: $KEY" || bad "산출물에 $KEY 없음"
+ done
+ case "$APP" in
+ *maccatalyst*)
+ echo "$ENT" | grep -q "com.apple.security.app-sandbox" \
+ && ok "산출물(맥): 샌드박스" || bad "맥 산출물에 샌드박스가 없다" ;;
+ esac
+ fi
+ fi
+fi
+
+echo " ─────────────────────────────────────"
+[ $FAIL -eq 0 ] && echo " 통과" || echo " 실패 — 위의 ❌를 보라"
+echo
+exit $FAIL
diff --git a/tools/quality_baseline.py b/tools/quality_baseline.py
new file mode 100755
index 0000000..459d044
--- /dev/null
+++ b/tools/quality_baseline.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""코드 품질 기준선을 센다.
+
+"지속 가능한 코드인가"는 느낌이라 그대로 두면 판정이 매번 달라진다. 셀 수 있는
+것만이라도 매번 같은 방법으로 세서, 리팩터링 전후를 비교할 수 있게 한다.
+
+근거: docs/verification.md "품질 검증을 어떻게 셀 것인가"
+기준선: docs/architecture/CURRENT.md "품질 기준선"
+
+ python3 tools/quality_baseline.py # 표로 출력
+ python3 tools/quality_baseline.py --tsv # 원장에 붙일 한 줄
+"""
+import argparse
+import re
+import subprocess
+import sys
+from collections import Counter
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SRC = ROOT / "Mosco"
+
+# 이 폴더들만 센다. DerivedData·빌드 산출물은 제외.
+AREAS = {
+ "App(조립)": SRC / "App", # Features·Common을 뺀 나머지 = RootTabView 등
+ "App/Features": SRC / "App" / "Features",
+ "App/Common": SRC / "App" / "Common",
+ "Shared": SRC / "Shared",
+ "MoscoWidget": SRC / "MoscoWidget",
+ "MoscoTests": SRC / "MoscoTests",
+}
+
+# 뷰 안에 있으면 곤란한 것들. 완벽한 판정은 불가능하고, 추세를 보는 용도다.
+LOGIC_IN_VIEW = {
+ "날짜 계산": re.compile(r"Calendar\.current|dateComponents\(|date\(bySetting|startOfDay"),
+ "정렬·필터": re.compile(r"\.sorted\s*[({]|\.filter\s*[({]|\.first\s*\{"),
+ "정규식·문자열 수술": re.compile(r"NSRegularExpression|removeSubrange|replacingOccurrences|components\(separatedBy"),
+ "저장소 접근": re.compile(r"modelContext\.(insert|delete|save)|FetchDescriptor"),
+}
+
+HARDCODED_FONT = re.compile(r"\.font\(\.system\(size:")
+# Metrics.* 가 아닌 숫자 여백
+HARDCODED_PADDING = re.compile(r"\.padding\((?:\.\w+,\s*)?\d")
+SINGLETON = re.compile(r"static\s+(?:let|var)\s+shared\b")
+COND_COMPILE = re.compile(r"#if\s+(?:!)?targetEnvironment|#if\s+canImport")
+TEMP_MARK = re.compile(r"//\s*TEMP:")
+TEST_CASE = re.compile(r"@Test\b|func\s+test[A-Z_]")
+
+
+def swift_files(base: Path, top_only: bool = False):
+ if not base.exists():
+ return []
+ pattern = base.glob("*.swift") if top_only else base.rglob("*.swift")
+ return sorted(pattern)
+
+
+def is_view_file(text: str) -> bool:
+ return ": View" in text or ": ViewModifier" in text
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--tsv", action="store_true", help="원장에 붙일 한 줄만 출력")
+ args = ap.parse_args()
+
+ stats = Counter()
+ per_area = {}
+ big_files = []
+ logic_hits = Counter()
+
+ for name, base in AREAS.items():
+ files = swift_files(base, top_only=(name == "App(조립)"))
+ lines = 0
+ for path in files:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ n = text.count("\n") + 1
+ lines += n
+ if n >= 500:
+ big_files.append((n, str(path.relative_to(ROOT))))
+
+ stats["fonts"] += len(HARDCODED_FONT.findall(text))
+ stats["paddings"] += len(HARDCODED_PADDING.findall(text))
+ stats["singletons"] += len(SINGLETON.findall(text))
+ stats["cond"] += len(COND_COMPILE.findall(text))
+ stats["temp"] += len(TEMP_MARK.findall(text))
+ if name == "MoscoTests":
+ stats["tests"] += len(TEST_CASE.findall(text))
+
+ # 뷰 파일 안의 비자명 로직
+ if name.startswith("App/") and is_view_file(text):
+ for label, pattern in LOGIC_IN_VIEW.items():
+ hits = len(pattern.findall(text))
+ if hits:
+ logic_hits[label] += hits
+
+ per_area[name] = (len(files), lines)
+ stats["files"] += len(files)
+ stats["lines"] += lines
+
+ if args.tsv:
+ head = subprocess.run(
+ ["git", "rev-parse", "--short", "HEAD"], cwd=ROOT,
+ capture_output=True, text=True).stdout.strip()
+ print("\t".join(str(x) for x in [
+ head, stats["files"], stats["lines"], stats["tests"],
+ sum(logic_hits.values()), len(big_files), stats["singletons"],
+ stats["cond"], stats["fonts"], stats["paddings"], stats["temp"],
+ ]))
+ return 0
+
+ print(f"\n 코드 품질 기준선 {stats['files']}파일 · {stats['lines']:,}줄\n")
+ print(" ─ 영역별 ─────────────────────────────")
+ for name, (count, lines) in per_area.items():
+ print(f" {name:<16} {count:>4}파일 {lines:>7,}줄")
+
+ print("\n ─ 테스트 ─────────────────────────────")
+ print(f" 테스트 수 {stats['tests']:>6}")
+ feature_tests = 0 # Features를 대상으로 하는 테스트는 구조상 존재할 수 없다
+ print(f" Features 대상 테스트 {feature_tests:>6} (테스트 타깃이 Shared/만 컴파일한다)")
+
+ print("\n ─ 뷰 안의 비자명 로직 ────────────────")
+ for label, count in logic_hits.most_common():
+ print(f" {label:<20} {count:>6}")
+ print(f" {'합계':<20} {sum(logic_hits.values()):>6} (낮을수록 좋음)")
+
+ print("\n ─ 덩치 ───────────────────────────────")
+ print(f" 500줄 넘는 파일 {len(big_files):>6}")
+ for n, path in sorted(big_files, reverse=True):
+ print(f" {n:>5}줄 {path}")
+
+ print("\n ─ 결합·우회 ──────────────────────────")
+ print(f" 싱글턴(static shared) {stats['singletons']:>6}")
+ print(f" 조건부 컴파일 분기 {stats['cond']:>6}")
+ print(f" 폰트 크기 하드코딩 {stats['fonts']:>6} (토큰 우회)")
+ print(f" 숫자 여백 {stats['paddings']:>6} (Metrics 우회 가능성)")
+
+ print("\n ─ 임시 코드 ──────────────────────────")
+ mark = "✅" if stats["temp"] == 0 else "⚠️"
+ print(f" {mark} // TEMP: 표식 {stats['temp']:>6} (0이어야 한다)")
+
+ print("\n 세지 못하는 것: 같은 로직의 복사본, Feature 간 순환 의존,")
+ print(" 추상화의 적정성. 이건 사람이 본다 (docs/review-criteria.md).\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())