From 084dc8cfe348554e592e917993932bd16289eb72 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 26 May 2026 17:26:08 +0900 Subject: [PATCH 01/30] =?UTF-8?q?docs:=20/diary-notion=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=E2=80=94=20=EC=97=B0=EB=8F=84=EB=B3=84=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20+=20=EB=8B=A8=EC=9D=BC=20DB=20+=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=8B=A8=EC=9C=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조: 루트 페이지 → 연도 페이지 → 단일 Entries DB (Project select 분류) - 작업 분리: 슬래시 커맨드 안의 Claude가 transcript를 semantic 단위로 N개 task 분리 - LLM: Claude Code 구독 활용 (별도 Anthropic API 키 X) - 셋업: claude-diary notion init 대화형 명령 (token + URL → page_id 파싱 + 권한 검증) - 멱등성: Session ID + Task Index hidden 컬럼, --force 시 archive&recreate - 에러: 401/403 fail fast, 400 skip, 429/5xx retry, 404 자동 재생성 Co-Authored-By: Claude Opus 4.7 --- .../diary-notion-hierarchical.design.md | 447 ++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 docs/02-design/features/diary-notion-hierarchical.design.md diff --git a/docs/02-design/features/diary-notion-hierarchical.design.md b/docs/02-design/features/diary-notion-hierarchical.design.md new file mode 100644 index 0000000..1cde607 --- /dev/null +++ b/docs/02-design/features/diary-notion-hierarchical.design.md @@ -0,0 +1,447 @@ +# /diary-notion — Hierarchical Notion Export + +> **Summary**: 슬래시 커맨드로 현재 세션을 작업 단위로 분리하여 Notion DB에 push (업무일지 자동화) +> +> **Project**: claude-code-hooks-diary +> **Date**: 2026-05-26 +> **Status**: Draft (설계 의논 중) + +## Executive Summary + +| 관점 | 내용 | +|------|------| +| **Problem** | 기존 NotionExporter는 단순 flat DB push만 가능. 세션 1개 = 행 1개. 업무일지로 보기에 부적합 | +| **Solution** | `/diary-notion` 슬래시 커맨드 — Claude가 세션을 작업 단위로 분리 → 연도별 페이지/단일 DB로 push | +| **Core Value** | 별도 API 키 없이 (Claude Code 구독만으로) 업무일지 자동화 | + +--- + +## 1. Overview + +### 1.1 Design Goals + +- 한 세션의 작업을 **의미 단위로 N개 행**으로 분리 +- **연도별 페이지 → 단일 통합 DB** 구조 (단순) +- Claude Code 구독만으로 동작 (별도 Anthropic API 키 불필요) +- Notion 무료 플랜에서도 동작 +- 기존 `exporters.notion` config 재사용 + +### 1.2 Design Principles + +- **Claude는 의미 분석만, CLI는 기계 처리만** — 역할 분리 +- **무료 path 우선** — 외부 의존성 최소화 +- **소프트 멱등** — 실수로 두 번 눌러도 데이터 깨지지 않음 + +### 1.3 Non-Goals + +- 자동 push (Stop Hook 통합) — 별도 단순 flat 모드(`exporters.notion`)가 담당 +- Notion 페이지를 다시 markdown으로 sync — 단방향 export only +- 과거 markdown 일지 일괄 Notion 마이그레이션 — 별도 명령(`sync-notion`)으로 분리 + +--- + +## 2. Notion Structure + +``` +[루트 페이지] ← 사용자가 미리 만들고 page_id를 config에 등록 + ├─ 📄 2026 ← 자동 생성 (연도) + │ └─ 🗄️ Entries (인라인 DB) ← 자동 생성 (연도당 1개) + │ ├─ 행: 2026-05-26 / Project: claude-diary / ... + │ ├─ 행: 2026-05-26 / Project: other-project / ... + │ └─ 행: 2026-05-27 / ... + ├─ 📄 2027 ← 다음 해 첫 push 시 자동 생성 + │ └─ 🗄️ Entries + │ └─ ... +``` + +### 2.1 왜 단일 DB + Project select? + +**대안 — 프로젝트별 인라인 DB**: +- 새 프로젝트 시작할 때마다 DB 추가 생성 필요 (API 호출 ↑) +- DB가 늘어나면 캐시 키 복잡 + +**채택 — 단일 DB + Project (select)**: +- DB는 연도당 1개만 (한 번 만들면 끝) +- Notion에서 Project로 group/filter view 자유롭게 생성 가능 +- 새 프로젝트 = select 옵션만 자동 추가 (Notion API가 처리) +- "오늘 여러 프로젝트 만진 거" 한눈에 보기 자연스러움 + +--- + +## 3. DB Schema + +### 3.1 Layer 1 — Properties (DB 뷰에서 보이는 컬럼) + +| 컬럼 | 타입 | 표시 | 값 예시 | 용도 | +|------|------|------|---------|------| +| Name | title | ✅ | "DB 컬럼 스키마 의논" | Claude가 뽑은 task 제목 | +| Date | date | ✅ | 2026-05-26 | 정렬/필터/캘린더 뷰 | +| Project | select | ✅ | claude-diary | group/filter | +| Categories | multi_select | ✅ | design, notion | 작업 성격 | +| Files | number | ✅ | 7 | 수정+생성 파일 수 | +| Commits | number | ✅ | 3 | 커밋 개수 | +| Lines | number | ✅ | 142 | 추가+삭제 합 | +| Session ID | rich_text | 🔒 hidden | "abc-123-def" | 멱등성 키 | +| Task Index | number | 🔒 hidden | 0, 1, 2 | 멱등성 키 | + +→ 표시 7개 + hidden 2개 = 총 9개. 의미 요약은 컬럼이 아닌 본문(`body_intro`)으로만 노출. + +### 3.2 Layer 2 — Page Body (행 클릭 시 보이는 markdown) + +```markdown +[body_intro - Claude가 작성한 1~3문장 의미 요약] + +## 사용자 요청 +- "..." +- "..." + +## 작업 요약 +- ... +- ... + +## 수정/생성 파일 +- src/... +- src/... + +## 실행한 명령 +- git log --oneline +- ... + +## Git 변경사항 +**Branch**: main +- `abc1234` feat: ... +- `def5678` test: ... +- (lines: +142 / -38) + +## 발생한 에러 +(있을 때만 표시) +``` + +**조립**: Claude가 만든 `body_intro` 1~3문장 + CLI가 raw 데이터로 조립한 기계 섹션들. + +--- + +## 4. JSON Schema (Slash Command → CLI) + +```json +{ + "session_id": "abc-123-def", + "tasks": [ + { + "title": "Notion DB 컬럼 스키마 결정", + "body_intro": "DB 컬럼을 Layer 1/2로 분리. 단일 통합 DB + Project select 채택. summary 컬럼은 본문 첫 문단(body_intro)으로 통합.", + "categories": ["design", "notion"], + "project": "claude-code-hooks-diary", + "user_prompts": ["DB 구조 의논하자", "name에는 날짜 말고..."], + "files_modified": ["src/claude_diary/exporters/notion.py"], + "files_created": [], + "commands_run": ["git log --oneline -20"], + "commit_hashes": ["abc1234"], + "errors": [] + } + ] +} +``` + +### 4.1 Claude의 책임 (슬래시 커맨드 instructions) + +- transcript를 작업 단위로 분리 +- 각 task의 `title` (30~50자 명사구), `body_intro` (1~3문장 평어체, 결과 중심) +- `categories` 추출 +- `user_prompts`, `files_modified`, `files_created`, `commands_run`, `errors` 추출 +- `commit_hashes`를 task에 매핑 + +### 4.2 CLI의 책임 + +- `commit_hashes`로 git 메타 수집 (message, lines, branch) — `git_info.py` 재사용 +- Layer 2 body markdown 조립 (`body_intro` + raw 섹션) — `formatter.py` 확장 +- 연도 페이지/DB 자동 생성 (없으면) +- 행 추가 (멱등성 처리 포함) +- 캐시 갱신 + +### 4.3 JSON 전달 방식 — 임시 파일 via cwd + +**선택**: Claude가 cwd에 임시 JSON 파일을 작성 → CLI에 `--input` 으로 경로 전달. + +``` +1. Claude: Write 도구로 cwd에 `.diary-notion-.json` 작성 +2. !`claude-diary notion-push --input .diary-notion-.json` +3. CLI: 파일 read → push → try/finally로 파일 삭제 (성공/실패 무관) +4. (보험) 슬래시 커맨드 마지막에서 한 번 더 삭제 시도 +``` + +**stdin 방식을 안 쓴 이유**: PowerShell은 `<<<` here-string 미지원. heredoc 문법도 bash와 다름 (`@'...'@`). cross-platform 호환을 위해 임시 파일이 안전. + +**보안**: +- JSON에 token 등 secret 없음 (token은 CLI가 config에서 직접 read) +- transcript 데이터 자체는 `secret_scanner.py` 가 push 전에 마스킹 +- 파일명에 `session_id` 단편 박아 충돌/추측 방지 +- 사용자 프로젝트 `.gitignore`에 `.diary-notion-*.json` 패턴 추가 권장 (README에 안내) + +--- + +## 5. Flow + +``` +[사용자] + /diary-notion 입력 + │ + ▼ +[Claude (현재 세션)] ── Claude Code 구독으로 동작 + ├─ transcript 분석 + ├─ 작업 단위 N개 분리 + ├─ 각 task: title / body_intro / summary / categories / prompts / files / commands / commit_hashes + └─ JSON 생성 + │ + ▼ !`claude-diary notion-push --stdin` +[CLI: notion-push 명령] + ├─ JSON 파싱 + ├─ commit_hashes → git_info.py로 메타+lines 수집 + ├─ Notion API 호출: + │ ├─ 캐시 확인: 연도 페이지 / DB ID + │ ├─ 없으면 생성: + │ │ ├─ 연도 페이지: POST /pages (parent=root_page_id) + │ │ └─ DB: POST /databases (parent=year_page_id, schema=10 columns) + │ ├─ 각 task: + │ │ ├─ 쿼리: Session ID + Task Index 매치 행 있나? + │ │ ├─ 있으면 skip (--force면 archive 후 재생성) + │ │ └─ 없으면 POST /pages (parent=db_id, properties+body) + │ └─ 캐시 갱신 + └─ 결과 출력 +``` + +--- + +## 6. Configuration + +### 6.1 기존 config 확장 + +```json +{ + "exporters": { + "notion": { + "enabled": false, ← Stop Hook 자동 push (별도) + "api_token": "secret_xxx", + "database_id": "...", ← 기존 flat 모드용 (legacy) + "root_page_id": "abc-123", ← 신규: hierarchical용 + "mode": "hierarchical" ← 신규: "flat" | "hierarchical" + } + } +} +``` + +- `enabled` flag는 자동 hook용. **`/diary-notion`은 enabled 무관하게 동작** (api_token + root_page_id만 있으면) +- 한 사용자가 두 모드 다 쓸 일은 거의 없지만, 기존 사용자 호환성 위해 둘 다 둠 + +### 6.2 환경변수 fallback + +- `CLAUDE_DIARY_NOTION_TOKEN` +- `CLAUDE_DIARY_NOTION_ROOT_PAGE_ID` + +### 6.3 Setup Command — `claude-diary notion init` + +대화형 셋업 명령. 처음 사용자가 한 번만 실행. + +**흐름**: +``` +$ claude-diary notion init + +Step 1/3: Integration token + Get it from: https://www.notion.so/my-integrations + Token (secret_...): █ + +Step 2/3: Root page URL or ID + Paste full Notion URL or page ID: + > https://www.notion.so/Working-Diary-abc123def456... + ✓ Parsed page_id: abc123def456 + +Step 3/3: Verifying access... + ✓ Token valid (GET /v1/users/me) + ✓ Integration can read root page (GET /v1/blocks/{id}) + +Saved to: /config.json + exporters.notion.api_token = secret_*** + exporters.notion.root_page_id = abc123def456 + exporters.notion.mode = hierarchical +``` + +**URL 파싱**: Notion URL 끝의 32자 hex (대시 유무 모두) 정규식 추출. plain page_id 입력도 그대로 통과. + +**Write 권한 검증 정책**: 검증 안 함 (실제로 child page 생성 시도는 부작용. read 권한 OK면 write도 따라옴). 첫 push에서 실패하면 그때 안내. + +**실패 시 안내**: +- 401: "Token이 잘못되었어요. https://www.notion.so/my-integrations 에서 다시 확인하세요" +- 404: "페이지에 Integration을 공유했나요? 페이지 우상단 ⋯ → Connections → Integration 추가" + +--- + +## 7. Caching + +### 7.1 캐시 파일 + +`/notion-cache.json`: + +```json +{ + "root_page_id": "abc-123", + "years": { + "2026": "page_id_2026" + }, + "databases": { + "2026": "db_id_xxx" + }, + "rows": { + "abc-123-def:0": "row_page_id_1", + "abc-123-def:1": "row_page_id_2" + } +} +``` + +- 키 `rows`의 형식: `:` → Notion 행 page_id +- 캐시 miss 시 → Notion 검색 → 캐시 갱신 +- Notion에서 사용자가 삭제 → 다음 호출 시 API 404 → 캐시 무효화 후 재생성 + +--- + +## 8. Idempotency + +### 8.1 기본: Soft Idempotency (skip) + +- push 직전 `query DB where Session_ID=X and Task_Index=Y` +- 매치되면 skip, 없으면 새 행 추가 +- 같은 세션 두 번 push: 첫 push의 행은 그대로, 새로 추가된 task만 push + +### 8.2 `--force`: Archive & Recreate + +- 같은 `session_id`의 모든 행을 archive (Notion API의 `archived: true`) +- 그 다음 모든 task를 새로 push +- 진짜 upsert (block-level update)는 복잡해서 채택 안 함 + +--- + +## 9. Decisions & Trade-offs + +| # | 결정 | 채택 | 이유 | +|---|------|------|------| +| 1 | 계층 구조 | A: 연도 → Entries DB → 행 | 연말 회고 자료로 한 페이지에 다 보임 | +| 2 | DB 분리 | 단일 DB + Project select | 새 프로젝트마다 DB 생성 X. Notion view로 분류 | +| 3 | DB 컬럼 | 8개 표시 + 2개 hidden | 답답하지 않으면서 멱등성 키 확보 | +| 4 | 작업 분리 | LLM (옵션 3: 슬래시 커맨드 안의 Claude) | API 키 X, 의미 단위 분리 가능 | +| 5 | LLM 호출 위치 | 슬래시 커맨드 = 현재 세션의 Claude | Claude Code 구독으로 무료. SDK 의존성 X | +| 6 | 본문 markdown | C: Claude의 intro + CLI의 raw 섹션 | 의미 정리 + 일관성 동시 확보 | +| 7 | git 정보 수집 | A: CLI가 자체 수집 | 정확도 ↑, `git_info.py` 재사용 | +| 8 | 멱등성 | B + `--force`: skip 기본, force는 archive&recreate | 실수 방지 + 강제 갱신 옵션 | +| 9 | 셋업 흐름 | B: `notion init` 대화형 명령 + URL 파싱 + token/read 검증 | 첫 인상 비용 ↓, page_id 헷갈림 해결, 권한 디버깅 비용 ↓ | +| 10 | 작업 분리 우선순위 | B: Semantic-first (의미 단위) | 의논 세션도 풍부, 큰 commit 안 묶임, Claude 정리 능력 활용 | +| 11 | Title 형식 | 명사구, 30~50자, 시제/주어/prefix/마침표 없음 | DB 뷰 한 줄에 들어감. 일관성 | +| 12 | Body intro 톤 | 평어체, 1~3문장, 결과 중심, markdown 강조 OK, 추측 금지 | 글로벌 지침과 일관. 회고 시 빠른 회상 | +| 13 | summary 컬럼 | 삭제 — `body_intro` 만 유지 | 사용자가 본문 위주로 보기 때문. 중복 제거 | +| 14 | JSON 전달 방식 | 임시 파일 (cwd, `.diary-notion-.json`) | PowerShell 호환. escape 문제 회피. 디버깅 쉬움 | + +--- + +## 10. Slash Command Instructions (확정) + +`~/.claude/commands/diary-notion.md` 본문 초안: + +```markdown +--- +description: 현재 세션을 작업 단위로 분리해 Notion 업무일지 DB에 push +allowed-tools: + - Bash + - Read + - Write +--- + +# /diary-notion + +현재 세션의 transcript와 git 정보를 분석하여 Notion 업무일지 DB에 push. + +## 단계 + +1. **컨텍스트 수집** + - 이 세션의 user 메시지, 너의 응답, 호출한 도구 검토 + - `git log` 로 이 세션 중 만든 commit 조회 (시간 추정 OK) + +2. **작업 단위 분리 (Semantic-first)** + - **의미 단위로 분리**가 기본. transcript의 사고 흐름 = task + - 한 commit이 여러 의미 단위에 걸치면 양쪽 task에 같은 hash 매핑 + - 큰 commit("fix: 5건 개선" 같은) 한 번에 묶지 말고 의미별로 분리 + - 짧은 follow-up("ㅇㅇ", "맞아")은 직전 task에 흡수 + - **commit이 0개인 의논 세션도 정상** — 의미 단위로 task N개 생성 + +3. **각 task별 추출** + - `title`: 30~50자 명사구. 시제/주어/prefix/마침표 없음 + - ✅ "Notion DB 컬럼 스키마 결정", "git_info.py 리팩토링" + - ❌ "오늘 DB 의논했다", "[설계] DB 컬럼" + - `body_intro`: 1~3문장, 200~500자, 평어체, 결과 중심 + - transcript에 없는 내용 추가 금지 (추측 X) + - markdown 강조(`**굵게**`, `` `코드` `` ) 사용 OK + - `categories`: 1~3개. design/refactor/bugfix/test/docs/infra/discussion 같은 자유 라벨 + - `project`: 현재 cwd의 폴더명 + - `user_prompts`, `files_modified`, `files_created`, `commands_run`, `errors` + - `commit_hashes`: 이 task에 해당하는 commit (0개도 OK) + +4. **JSON 출력 및 CLI 호출** + - cwd에 `.diary-notion-<8자리>.json` 작성 (Write 도구) + - `!claude-diary notion-push --input .diary-notion-<8자리>.json` 실행 + - 종료 후 파일 삭제 + +## JSON 형식 + +JSON Schema는 design 문서 Section 4 참고. + +## 빈 결과 처리 + +tasks 가 0개라면 (transcript에 의미 있는 작업 없음) 사용자에게 이유 설명하고 CLI 호출 없이 종료. + +## 사용자 보고 + +CLI 결과를 그대로 보여주고 push/skip된 task 요약. +``` + +--- + +## 11. Open Questions (TBD) + +남은 결정: + +1. **캐싱 + 에러 처리** — 캐시 무효화 트리거, Notion API 실패 시 retry queue 재사용 여부 + +--- + +## 11. Reuse vs New Code + +### 11.1 재사용 + +- `exporters/base.py` `BaseExporter` 인터페이스 +- `exporters/notion.py` `NotionExporter` (flat 모드는 그대로, mode 분기 추가) +- `lib/git_info.py` commit 메타 수집 +- `lib/secret_scanner.py` 시크릿 마스킹 +- `formatter.py` 본문 markdown 조립 (확장) +- `cli/setup.py` 슬래시 커맨드 install/uninstall 패턴 + +### 11.2 신규 + +- `cli/notion_push.py` — `claude-diary notion-push --stdin` 명령 +- `exporters/notion.py` 안에 `_hierarchical_export()` 추가 (또는 `NotionHierarchicalExporter` 클래스 분리) +- `lib/notion_cache.py` — 캐시 read/write +- `~/.claude/commands/diary-notion.md` — 슬래시 커맨드 instructions +- `tests/test_notion_push.py` +- `tests/test_notion_cache.py` + +--- + +## 12. Compatibility & Migration + +- 기존 `exporters.notion` (flat 모드) 사용자는 영향 없음 — `mode` 키 없으면 flat 동작 +- 기존 Stop Hook 자동 push는 그대로 동작 +- `/diary-notion`은 신규 사용자가 추가 셋업해야 사용 가능 (`root_page_id` 등록) + +--- + +## 13. Security + +- `api_token`은 config.json 평문 저장 (기존과 동일) +- 셋업 가이드에 "config.json 공유/커밋 금지" 강조 필요 +- secret_scanner가 push 전에 entry_data를 마스킹 (기존 로직 재사용) From b64c3007e4b6c161edf8f8e54c2e56918421f910 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 26 May 2026 17:43:39 +0900 Subject: [PATCH 02/30] =?UTF-8?q?docs:=20Branch=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20'branch=20=EA=B2=BD=EA=B3=84=20=3D=20t?= =?UTF-8?q?ask=20=EA=B2=BD=EA=B3=84'=20=EB=B6=84=EB=A6=AC=20=EB=A3=B0=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB 스키마: Branch (select) 컬럼 추가, 표시 7→8개. CLI가 자동 채움 (commit 있으면 그 branch, 없으면 HEAD branch) - 작업 분리 룰 보완: branch 경계 최우선 → 의미 단위 세션 중 git switch 하면 무조건 새 task로 분리. 한 task = 한 branch 보장 - 결정 표에 #15(Branch) + #16~19(에러처리/retry/캐시/부분실패) 채움 Co-Authored-By: Claude Opus 4.7 --- .../diary-notion-hierarchical.design.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/02-design/features/diary-notion-hierarchical.design.md b/docs/02-design/features/diary-notion-hierarchical.design.md index 1cde607..f5d4872 100644 --- a/docs/02-design/features/diary-notion-hierarchical.design.md +++ b/docs/02-design/features/diary-notion-hierarchical.design.md @@ -77,6 +77,7 @@ | Name | title | ✅ | "DB 컬럼 스키마 의논" | Claude가 뽑은 task 제목 | | Date | date | ✅ | 2026-05-26 | 정렬/필터/캘린더 뷰 | | Project | select | ✅ | claude-diary | group/filter | +| Branch | select | ✅ | feat/diary-notion | group/filter. CLI가 자동 채움 | | Categories | multi_select | ✅ | design, notion | 작업 성격 | | Files | number | ✅ | 7 | 수정+생성 파일 수 | | Commits | number | ✅ | 3 | 커밋 개수 | @@ -84,7 +85,12 @@ | Session ID | rich_text | 🔒 hidden | "abc-123-def" | 멱등성 키 | | Task Index | number | 🔒 hidden | 0, 1, 2 | 멱등성 키 | -→ 표시 7개 + hidden 2개 = 총 9개. 의미 요약은 컬럼이 아닌 본문(`body_intro`)으로만 노출. +→ 표시 8개 + hidden 2개 = 총 10개. 의미 요약은 컬럼이 아닌 본문(`body_intro`)으로만 노출. + +**Branch 컬럼 데이터 소스** (CLI 자동): +- task의 `commit_hashes` 있으면 → 첫 commit의 branch (`git branch --contains`) +- `commit_hashes` 없으면 → 현재 HEAD branch (`git rev-parse --abbrev-ref HEAD`) +- HEAD detached면 → fallback으로 commit hash 단편 또는 빈 값 ### 3.2 Layer 2 — Page Body (행 클릭 시 보이는 markdown) @@ -154,6 +160,7 @@ ### 4.2 CLI의 책임 - `commit_hashes`로 git 메타 수집 (message, lines, branch) — `git_info.py` 재사용 +- task별 Branch 자동 결정 (commit 있으면 첫 commit의 branch, 없으면 HEAD branch) - Layer 2 body markdown 조립 (`body_intro` + raw 섹션) — `formatter.py` 확장 - 연도 페이지/DB 자동 생성 (없으면) - 행 추가 (멱등성 처리 포함) @@ -337,6 +344,11 @@ Saved to: /config.json | 12 | Body intro 톤 | 평어체, 1~3문장, 결과 중심, markdown 강조 OK, 추측 금지 | 글로벌 지침과 일관. 회고 시 빠른 회상 | | 13 | summary 컬럼 | 삭제 — `body_intro` 만 유지 | 사용자가 본문 위주로 보기 때문. 중복 제거 | | 14 | JSON 전달 방식 | 임시 파일 (cwd, `.diary-notion-.json`) | PowerShell 호환. escape 문제 회피. 디버깅 쉬움 | +| 15 | Branch 컬럼 추가 + 경계 룰 | Branch select 컬럼 + "branch 다르면 task 분리"를 최우선 분리 룰로 | 한 task = 한 branch 보장. select 컬럼이 의미 있어짐. 여러 branch 섞이는 케이스 자동 해결 | +| 16 | Error 종류별 분기 | 401/403 fail fast, 400 skip, 429/5xx retry, 404 자동 재생성 | 의미 없는 retry 방지. 캐시 일관성 자동 복구 | +| 17 | Retry 정책 | 인라인 retry 3회 (exponential backoff) + JSON 파일 보존 | 수동 명령에 동기적 보고. queue 안 씀 | +| 18 | 캐시 무효화 | Lazy (404 응답 시) | 정상 path 빠름 | +| 19 | 부분 실패 | Continue + 종합 보고 (`Pushed N, skipped M, failed K`) | 행 단위 독립 | --- @@ -363,8 +375,9 @@ allowed-tools: - 이 세션의 user 메시지, 너의 응답, 호출한 도구 검토 - `git log` 로 이 세션 중 만든 commit 조회 (시간 추정 OK) -2. **작업 단위 분리 (Semantic-first)** - - **의미 단위로 분리**가 기본. transcript의 사고 흐름 = task +2. **작업 단위 분리 (Branch 경계 → Semantic-first)** + - **branch 경계 최우선**: 세션 중 `git switch`로 branch가 바뀌면 무조건 새 task로 분리 + - 같은 branch 안에서는 **의미 단위로 분리** (사고 흐름 = task) - 한 commit이 여러 의미 단위에 걸치면 양쪽 task에 같은 hash 매핑 - 큰 commit("fix: 5건 개선" 같은) 한 번에 묶지 말고 의미별로 분리 - 짧은 follow-up("ㅇㅇ", "맞아")은 직전 task에 흡수 @@ -404,9 +417,7 @@ CLI 결과를 그대로 보여주고 push/skip된 task 요약. ## 11. Open Questions (TBD) -남은 결정: - -1. **캐싱 + 에러 처리** — 캐시 무효화 트리거, Notion API 실패 시 retry queue 재사용 여부 +모든 설계 결정 완료. 다음 단계 = 구현. --- From f4d113862f2a75f15404fab21d9da42d416bcd75 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 26 May 2026 17:54:28 +0900 Subject: [PATCH 03/30] feat: Notion hierarchical exporter + ID cache (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/notion_cache.py: 연도 페이지/DB/행 ID 캐시 (config의 root_page_id 변경 시 자동 무효화) - exporters/notion_hierarchical.py: Notion API 클라이언트 + 페이지/DB/행 CRUD - HTTP retry: 429 Retry-After 준수, 5xx exponential backoff (3회) - 에러 분기: 401/403→NotionAuthError, 404→NotionNotFound, 400→NotionBadRequest - 멱등성: Session ID + Task Index로 행 검색 - --force용 archive_rows_for_session - 새 테스트 34개 (전체 477 통과) Co-Authored-By: Claude Opus 4.7 --- .../exporters/notion_hierarchical.py | 326 +++++++++++++++++ src/claude_diary/lib/notion_cache.py | 124 +++++++ tests/test_notion_cache.py | 123 +++++++ tests/test_notion_hierarchical.py | 335 ++++++++++++++++++ 4 files changed, 908 insertions(+) create mode 100644 src/claude_diary/exporters/notion_hierarchical.py create mode 100644 src/claude_diary/lib/notion_cache.py create mode 100644 tests/test_notion_cache.py create mode 100644 tests/test_notion_hierarchical.py diff --git a/src/claude_diary/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py new file mode 100644 index 0000000..eb629af --- /dev/null +++ b/src/claude_diary/exporters/notion_hierarchical.py @@ -0,0 +1,326 @@ +"""Notion hierarchical exporter — pushes task entries to a year/DB/row hierarchy. + +Structure (on Notion): + [Root page (user-specified)] + ├─ 2026 (page, auto-created) + │ └─ Entries (database, auto-created, 10 columns) + │ ├─ row 1 + │ └─ row 2 + ├─ 2027 + └─ ... + +Used by `/diary-notion` slash command via `claude-diary notion-push`. +Separate from the flat-mode NotionExporter (Stop Hook auto-push). + +Error handling policy: + 401/403 → fail fast (auth/permission problem affects all tasks) + 400 → skip this task, continue with the rest + 404 → invalidate cache, auto-recreate parent, retry + 429 → respect Retry-After, inline retry + 5xx / network → exponential backoff, inline retry (max 3) +""" + +import time + +from claude_diary.log import get_logger +from claude_diary.lib import notion_cache + +logger = get_logger("claude_diary.exporters.notion_hierarchical") + + +NOTION_API_VERSION = "2022-06-28" +NOTION_API_BASE = "https://api.notion.com/v1" +MAX_RETRIES = 3 +RICH_TEXT_LIMIT = 2000 + + +class NotionAuthError(Exception): + """401/403 — token invalid or page not shared with integration.""" + + +class NotionNotFound(Exception): + """404 — parent page/db was deleted out from under us.""" + + +class NotionBadRequest(Exception): + """400 — malformed properties for this specific row.""" + + +class NotionHierarchicalExporter: + """Pushes tasks to the year/database/row hierarchy. + + Unlike BaseExporter subclasses, this is invoked directly by the + `notion-push` CLI with a list of tasks, not a single entry_data. + """ + + def __init__(self, config): + self.api_token = config.get("api_token") + self.root_page_id = config.get("root_page_id") + self._session = None + self._cache = None + + def validate_config(self): + return bool(self.api_token) and bool(self.root_page_id) + + def _ensure_requests(self): + try: + import requests + return requests + except ImportError: + raise RuntimeError( + "Notion exporter requires 'requests'. Install with: pip install requests" + ) + + def _headers(self): + return { + "Authorization": "Bearer %s" % self.api_token, + "Content-Type": "application/json", + "Notion-Version": NOTION_API_VERSION, + } + + def _request(self, method, path, json_body=None): + """HTTP wrapper with retry + error categorization. + + Returns parsed JSON on 200. Raises typed exceptions for known errors. + """ + requests = self._ensure_requests() + url = "%s%s" % (NOTION_API_BASE, path) + + last_error = None + for attempt in range(MAX_RETRIES): + try: + resp = requests.request( + method, url, + headers=self._headers(), + json=json_body, + timeout=15, + ) + except Exception as e: + last_error = e + if attempt < MAX_RETRIES - 1: + time.sleep(2 ** attempt) + continue + raise RuntimeError("Notion API network error: %s" % e) + + status = resp.status_code + if status == 200: + return resp.json() + + if status == 401 or status == 403: + raise NotionAuthError( + "Notion API %d: %s" % (status, _short_error(resp)) + ) + + if status == 404: + raise NotionNotFound( + "Notion API 404: %s" % _short_error(resp) + ) + + if status == 400: + raise NotionBadRequest( + "Notion API 400: %s" % _short_error(resp) + ) + + if status == 429: + retry_after = int(resp.headers.get("Retry-After", "1")) + time.sleep(min(retry_after, 30)) + last_error = "rate limited" + continue + + if 500 <= status < 600: + if attempt < MAX_RETRIES - 1: + time.sleep(2 ** attempt) + last_error = "5xx (%d)" % status + continue + raise RuntimeError( + "Notion API %d after %d retries: %s" % + (status, MAX_RETRIES, _short_error(resp)) + ) + + raise RuntimeError( + "Notion API unexpected status %d: %s" % + (status, _short_error(resp)) + ) + + raise RuntimeError("Notion API failed after retries: %s" % last_error) + + def load_cache(self): + self._cache = notion_cache.load(self.root_page_id) + return self._cache + + def save_cache(self): + if self._cache is not None: + notion_cache.save(self._cache) + + def ensure_year_page(self, year): + """Get year page ID, creating if missing. + + Cache hit + page still exists → return cached ID. + Cache hit but 404 → invalidate, recreate. + Cache miss → search children of root page, create if not found. + """ + cached = notion_cache.get_year_page(self._cache, year) + if cached: + try: + self._request("GET", "/blocks/%s" % cached) + return cached + except NotionNotFound: + logger.warning("Year page %s not found in Notion, recreating", year) + notion_cache.invalidate_year(self._cache, year) + + existing = self._find_child_page(self.root_page_id, str(year)) + if existing: + notion_cache.set_year_page(self._cache, year, existing) + return existing + + created = self._create_year_page(year) + notion_cache.set_year_page(self._cache, year, created) + return created + + def _find_child_page(self, parent_id, title): + """Scan parent's children for a child_page with matching title.""" + cursor = None + while True: + path = "/blocks/%s/children?page_size=100" % parent_id + if cursor: + path += "&start_cursor=%s" % cursor + data = self._request("GET", path) + for block in data.get("results", []): + if block.get("type") != "child_page": + continue + block_title = block.get("child_page", {}).get("title", "") + if block_title == title: + return block["id"] + if not data.get("has_more"): + return None + cursor = data.get("next_cursor") + + def _create_year_page(self, year): + body = { + "parent": {"page_id": self.root_page_id}, + "properties": { + "title": [{"text": {"content": str(year)}}], + }, + } + resp = self._request("POST", "/pages", body) + return resp["id"] + + def ensure_database(self, year): + """Get Entries database ID for a year, creating if missing.""" + cached = notion_cache.get_database(self._cache, year) + if cached: + try: + self._request("GET", "/databases/%s" % cached) + return cached + except NotionNotFound: + logger.warning("Database for %s not found, recreating", year) + notion_cache.set_database(self._cache, year, None) + + year_page_id = self.ensure_year_page(year) + db_id = self._create_database(year_page_id) + notion_cache.set_database(self._cache, year, db_id) + return db_id + + def _create_database(self, parent_page_id): + """Create the Entries inline database with the agreed schema. + + Schema (decision #3, #15): + Name (title), Date, Project (select), Branch (select), + Categories (multi_select), Files, Commits, Lines (number), + Session ID (rich_text — hidden), Task Index (number — hidden) + """ + body = { + "parent": {"type": "page_id", "page_id": parent_page_id}, + "is_inline": True, + "title": [{"text": {"content": "Entries"}}], + "properties": { + "Name": {"title": {}}, + "Date": {"date": {}}, + "Project": {"select": {}}, + "Branch": {"select": {}}, + "Categories": {"multi_select": {}}, + "Files": {"number": {}}, + "Commits": {"number": {}}, + "Lines": {"number": {}}, + "Session ID": {"rich_text": {}}, + "Task Index": {"number": {}}, + }, + } + resp = self._request("POST", "/databases", body) + return resp["id"] + + def find_existing_row(self, db_id, session_id, task_index): + """Return row page ID if a row with the same Session ID + Task Index exists.""" + cached = notion_cache.get_row(self._cache, session_id, task_index) + if cached: + try: + self._request("GET", "/pages/%s" % cached) + return cached + except NotionNotFound: + notion_cache.invalidate_row(self._cache, session_id, task_index) + + body = { + "filter": { + "and": [ + {"property": "Session ID", "rich_text": {"equals": session_id}}, + {"property": "Task Index", "number": {"equals": task_index}}, + ] + }, + "page_size": 1, + } + try: + resp = self._request("POST", "/databases/%s/query" % db_id, body) + except NotionNotFound: + return None + results = resp.get("results", []) + if not results: + return None + row_id = results[0]["id"] + notion_cache.set_row(self._cache, session_id, task_index, row_id) + return row_id + + def archive_rows_for_session(self, db_id, session_id): + """Archive (soft-delete) all rows whose Session ID matches. + + Used by --force to allow a clean re-push. + """ + body = { + "filter": { + "property": "Session ID", + "rich_text": {"equals": session_id}, + }, + "page_size": 100, + } + archived = 0 + cursor = None + while True: + if cursor: + body["start_cursor"] = cursor + resp = self._request("POST", "/databases/%s/query" % db_id, body) + for row in resp.get("results", []): + self._request("PATCH", "/pages/%s" % row["id"], {"archived": True}) + archived += 1 + if not resp.get("has_more"): + break + cursor = resp.get("next_cursor") + notion_cache.invalidate_rows_for_session(self._cache, session_id) + return archived + + def create_row(self, db_id, properties, body_blocks): + """Create a new row (page) in the database with properties + body blocks.""" + body = { + "parent": {"database_id": db_id}, + "properties": properties, + "children": body_blocks, + } + resp = self._request("POST", "/pages", body) + return resp["id"] + + +def _short_error(resp): + """Extract a one-line error description from a Notion error response.""" + try: + data = resp.json() + return data.get("message") or data.get("code") or resp.text[:200] + except Exception: + return resp.text[:200] diff --git a/src/claude_diary/lib/notion_cache.py b/src/claude_diary/lib/notion_cache.py new file mode 100644 index 0000000..8ff33ab --- /dev/null +++ b/src/claude_diary/lib/notion_cache.py @@ -0,0 +1,124 @@ +"""Notion ID cache — avoids re-querying year pages, databases, and rows. + +Cache layout (`/notion-cache.json`): + { + "root_page_id": "abc-123", + "years": { "2026": "page_id_xxx" }, + "databases": { "2026": "db_id_xxx" }, + "rows": { ":": "row_page_id" } + } + +If config's root_page_id changes, the whole cache is invalidated on load +(user switched root page → all child IDs are stale). +""" + +import json +import os +from pathlib import Path + +from claude_diary.config import get_config_dir + + +CACHE_FILENAME = "notion-cache.json" + + +def _cache_path(): + return os.path.join(get_config_dir(), CACHE_FILENAME) + + +def load(root_page_id): + """Load cache from disk. + + If the on-disk root_page_id doesn't match the current config's root_page_id, + the cache is treated as stale and an empty cache is returned. + """ + path = _cache_path() + if not os.path.exists(path): + return _empty(root_page_id) + + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, IOError): + return _empty(root_page_id) + + if data.get("root_page_id") != root_page_id: + return _empty(root_page_id) + + return { + "root_page_id": root_page_id, + "years": data.get("years") or {}, + "databases": data.get("databases") or {}, + "rows": data.get("rows") or {}, + } + + +def save(cache): + """Persist cache to disk.""" + path = _cache_path() + Path(os.path.dirname(path)).mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(cache, f, indent=2, ensure_ascii=False) + + +def _empty(root_page_id): + return { + "root_page_id": root_page_id, + "years": {}, + "databases": {}, + "rows": {}, + } + + +def get_year_page(cache, year): + return cache["years"].get(str(year)) + + +def set_year_page(cache, year, page_id): + cache["years"][str(year)] = page_id + + +def get_database(cache, year): + return cache["databases"].get(str(year)) + + +def set_database(cache, year, db_id): + cache["databases"][str(year)] = db_id + + +def _row_key(session_id, task_index): + return "%s:%d" % (session_id, task_index) + + +def get_row(cache, session_id, task_index): + return cache["rows"].get(_row_key(session_id, task_index)) + + +def set_row(cache, session_id, task_index, row_id): + cache["rows"][_row_key(session_id, task_index)] = row_id + + +def invalidate_year(cache, year): + """Remove a year page and its dependent database + rows. + + Used when Notion API returns 404 — page was deleted by user. + """ + year_key = str(year) + cache["years"].pop(year_key, None) + cache["databases"].pop(year_key, None) + # Rows aren't tagged with year, but row IDs against a missing DB are + # useless. Clear them all — conservative but safe (just means next push + # has to re-query). + cache["rows"] = {} + + +def invalidate_row(cache, session_id, task_index): + cache["rows"].pop(_row_key(session_id, task_index), None) + + +def invalidate_rows_for_session(cache, session_id): + """Remove all row entries for a session (used by --force).""" + prefix = "%s:" % session_id + cache["rows"] = { + k: v for k, v in cache["rows"].items() if not k.startswith(prefix) + } diff --git a/tests/test_notion_cache.py b/tests/test_notion_cache.py new file mode 100644 index 0000000..820d589 --- /dev/null +++ b/tests/test_notion_cache.py @@ -0,0 +1,123 @@ +"""Tests for Notion ID cache.""" + +import json +import os +from unittest.mock import patch + +from claude_diary.lib import notion_cache + + +def _patch_cache_dir(tmp_path): + return patch( + "claude_diary.lib.notion_cache.get_config_dir", + return_value=str(tmp_path), + ) + + +class TestLoadSave: + def test_load_missing_file_returns_empty(self, tmp_path): + with _patch_cache_dir(tmp_path): + cache = notion_cache.load("root_abc") + assert cache["root_page_id"] == "root_abc" + assert cache["years"] == {} + assert cache["databases"] == {} + assert cache["rows"] == {} + + def test_save_then_load_roundtrips(self, tmp_path): + with _patch_cache_dir(tmp_path): + cache = notion_cache.load("root_abc") + notion_cache.set_year_page(cache, 2026, "page_2026") + notion_cache.set_database(cache, 2026, "db_2026") + notion_cache.set_row(cache, "sess1", 0, "row_xyz") + notion_cache.save(cache) + + loaded = notion_cache.load("root_abc") + + assert loaded["years"]["2026"] == "page_2026" + assert loaded["databases"]["2026"] == "db_2026" + assert loaded["rows"]["sess1:0"] == "row_xyz" + + def test_root_change_invalidates_cache(self, tmp_path): + """If config's root_page_id changes, the on-disk cache is discarded.""" + with _patch_cache_dir(tmp_path): + cache = notion_cache.load("root_old") + notion_cache.set_year_page(cache, 2026, "page_old") + notion_cache.save(cache) + + # Different root_page_id → fresh empty cache + cache2 = notion_cache.load("root_new") + + assert cache2["years"] == {} + + def test_corrupt_file_returns_empty(self, tmp_path): + cache_file = tmp_path / "notion-cache.json" + cache_file.write_text("not valid json {{{", encoding="utf-8") + with _patch_cache_dir(tmp_path): + cache = notion_cache.load("root_abc") + assert cache["years"] == {} + + +class TestGetSet: + def test_year_page_roundtrip(self): + cache = notion_cache._empty("root") + assert notion_cache.get_year_page(cache, 2026) is None + notion_cache.set_year_page(cache, 2026, "page_id") + assert notion_cache.get_year_page(cache, 2026) == "page_id" + + def test_database_roundtrip(self): + cache = notion_cache._empty("root") + notion_cache.set_database(cache, 2026, "db_id") + assert notion_cache.get_database(cache, 2026) == "db_id" + + def test_row_roundtrip(self): + cache = notion_cache._empty("root") + notion_cache.set_row(cache, "sess1", 0, "row_id") + assert notion_cache.get_row(cache, "sess1", 0) == "row_id" + # Different task_index → different key + assert notion_cache.get_row(cache, "sess1", 1) is None + + +class TestInvalidate: + def test_invalidate_year_clears_year_db_and_rows(self): + cache = notion_cache._empty("root") + notion_cache.set_year_page(cache, 2026, "page_2026") + notion_cache.set_database(cache, 2026, "db_2026") + notion_cache.set_row(cache, "sess1", 0, "row_xyz") + + notion_cache.invalidate_year(cache, 2026) + + assert notion_cache.get_year_page(cache, 2026) is None + assert notion_cache.get_database(cache, 2026) is None + assert cache["rows"] == {} + + def test_invalidate_year_preserves_other_years(self): + cache = notion_cache._empty("root") + notion_cache.set_year_page(cache, 2026, "page_2026") + notion_cache.set_year_page(cache, 2027, "page_2027") + + notion_cache.invalidate_year(cache, 2026) + + assert notion_cache.get_year_page(cache, 2026) is None + assert notion_cache.get_year_page(cache, 2027) == "page_2027" + + def test_invalidate_row_removes_specific_row(self): + cache = notion_cache._empty("root") + notion_cache.set_row(cache, "sess1", 0, "row_a") + notion_cache.set_row(cache, "sess1", 1, "row_b") + + notion_cache.invalidate_row(cache, "sess1", 0) + + assert notion_cache.get_row(cache, "sess1", 0) is None + assert notion_cache.get_row(cache, "sess1", 1) == "row_b" + + def test_invalidate_rows_for_session(self): + cache = notion_cache._empty("root") + notion_cache.set_row(cache, "sess1", 0, "row_a") + notion_cache.set_row(cache, "sess1", 1, "row_b") + notion_cache.set_row(cache, "sess2", 0, "row_c") + + notion_cache.invalidate_rows_for_session(cache, "sess1") + + assert notion_cache.get_row(cache, "sess1", 0) is None + assert notion_cache.get_row(cache, "sess1", 1) is None + assert notion_cache.get_row(cache, "sess2", 0) == "row_c" diff --git a/tests/test_notion_hierarchical.py b/tests/test_notion_hierarchical.py new file mode 100644 index 0000000..4878f3b --- /dev/null +++ b/tests/test_notion_hierarchical.py @@ -0,0 +1,335 @@ +"""Tests for NotionHierarchicalExporter — HTTP layer + page/DB/row CRUD.""" + +from unittest.mock import patch, MagicMock, call + +import pytest + +from claude_diary.exporters.notion_hierarchical import ( + NotionHierarchicalExporter, + NotionAuthError, + NotionNotFound, + NotionBadRequest, +) + + +def _make_response(status, json_body=None, headers=None): + resp = MagicMock() + resp.status_code = status + resp.json.return_value = json_body or {} + resp.headers = headers or {} + resp.text = "" + return resp + + +def _patch_requests(mock_requests): + """Helper: install a mock requests module.""" + return patch.dict("sys.modules", {"requests": mock_requests}) + + +def _make_exporter(): + return NotionHierarchicalExporter({ + "api_token": "secret_xxx", + "root_page_id": "root_abc", + }) + + +class TestValidateConfig: + def test_both_present(self): + assert _make_exporter().validate_config() is True + + def test_missing_token(self): + exp = NotionHierarchicalExporter({"root_page_id": "root_abc"}) + assert exp.validate_config() is False + + def test_missing_root_page(self): + exp = NotionHierarchicalExporter({"api_token": "secret_xxx"}) + assert exp.validate_config() is False + + +class TestRequestErrorMapping: + def test_200_returns_json(self): + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "page_xyz"}) + with _patch_requests(mock_req): + exp = _make_exporter() + data = exp._request("GET", "/pages/page_xyz") + assert data["id"] == "page_xyz" + + def test_401_raises_auth_error(self): + mock_req = MagicMock() + mock_req.request.return_value = _make_response(401, {"message": "unauthorized"}) + with _patch_requests(mock_req): + exp = _make_exporter() + with pytest.raises(NotionAuthError): + exp._request("GET", "/pages/x") + + def test_403_raises_auth_error(self): + mock_req = MagicMock() + mock_req.request.return_value = _make_response(403, {"message": "forbidden"}) + with _patch_requests(mock_req): + with pytest.raises(NotionAuthError): + _make_exporter()._request("GET", "/pages/x") + + def test_404_raises_not_found(self): + mock_req = MagicMock() + mock_req.request.return_value = _make_response(404, {"message": "not found"}) + with _patch_requests(mock_req): + with pytest.raises(NotionNotFound): + _make_exporter()._request("GET", "/pages/x") + + def test_400_raises_bad_request(self): + mock_req = MagicMock() + mock_req.request.return_value = _make_response(400, {"message": "bad"}) + with _patch_requests(mock_req): + with pytest.raises(NotionBadRequest): + _make_exporter()._request("POST", "/pages", {"x": 1}) + + def test_429_then_200_retries(self): + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(429, headers={"Retry-After": "0"}), + _make_response(200, {"ok": True}), + ] + with _patch_requests(mock_req), \ + patch("claude_diary.exporters.notion_hierarchical.time.sleep"): + data = _make_exporter()._request("GET", "/pages/x") + assert data["ok"] is True + assert mock_req.request.call_count == 2 + + def test_5xx_then_200_retries(self): + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(500), + _make_response(200, {"ok": True}), + ] + with _patch_requests(mock_req), \ + patch("claude_diary.exporters.notion_hierarchical.time.sleep"): + data = _make_exporter()._request("GET", "/pages/x") + assert data["ok"] is True + assert mock_req.request.call_count == 2 + + def test_5xx_exhausted_raises_runtime(self): + mock_req = MagicMock() + mock_req.request.return_value = _make_response(503) + with _patch_requests(mock_req), \ + patch("claude_diary.exporters.notion_hierarchical.time.sleep"): + with pytest.raises(RuntimeError): + _make_exporter()._request("GET", "/pages/x") + # MAX_RETRIES attempts + assert mock_req.request.call_count == 3 + + def test_network_error_retries(self): + mock_req = MagicMock() + mock_req.request.side_effect = [ + Exception("connection reset"), + _make_response(200, {"ok": True}), + ] + with _patch_requests(mock_req), \ + patch("claude_diary.exporters.notion_hierarchical.time.sleep"): + data = _make_exporter()._request("GET", "/pages/x") + assert data["ok"] is True + + +class TestEnsureYearPage: + def test_cache_hit_and_still_exists(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + # Pre-populate cache + exp._cache["years"]["2026"] = "cached_page" + + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "cached_page"}) + with _patch_requests(mock_req): + page_id = exp.ensure_year_page(2026) + assert page_id == "cached_page" + # Only the existence-check call, no creation + assert mock_req.request.call_count == 1 + + def test_cache_hit_but_404_triggers_recreate(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["years"]["2026"] = "stale_page" + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(404), # existence check fails + _make_response(200, {"results": [], "has_more": False}), # search finds nothing + _make_response(200, {"id": "new_page"}), # create + ] + with _patch_requests(mock_req): + page_id = exp.ensure_year_page(2026) + + assert page_id == "new_page" + assert exp._cache["years"]["2026"] == "new_page" + + def test_cache_miss_finds_existing(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, { + "results": [ + {"id": "found_page", "type": "child_page", + "child_page": {"title": "2026"}}, + ], + "has_more": False, + }) + with _patch_requests(mock_req): + page_id = exp.ensure_year_page(2026) + + assert page_id == "found_page" + assert exp._cache["years"]["2026"] == "found_page" + + def test_cache_miss_not_found_creates(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, {"results": [], "has_more": False}), + _make_response(200, {"id": "created_page"}), + ] + with _patch_requests(mock_req): + page_id = exp.ensure_year_page(2026) + + assert page_id == "created_page" + # Verify create call used title "2026" + create_call = mock_req.request.call_args_list[1] + body = create_call.kwargs["json"] + assert body["parent"]["page_id"] == "root_abc" + assert body["properties"]["title"][0]["text"]["content"] == "2026" + + +class TestEnsureDatabase: + def test_creates_database_with_full_schema(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["years"]["2026"] = "year_page" # year already cached + + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "db_xyz"}) + with _patch_requests(mock_req): + db_id = exp.ensure_database(2026) + + assert db_id == "db_xyz" + # Inspect the database-create POST body + create_body = mock_req.request.call_args.kwargs["json"] + assert create_body["parent"]["page_id"] == "year_page" + assert create_body["is_inline"] is True + props = create_body["properties"] + for col in ["Name", "Date", "Project", "Branch", "Categories", + "Files", "Commits", "Lines", "Session ID", "Task Index"]: + assert col in props + + +class TestFindExistingRow: + def test_returns_none_when_no_match(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"results": []}) + with _patch_requests(mock_req): + row = exp.find_existing_row("db_xyz", "sess1", 0) + assert row is None + + def test_finds_and_caches_existing_row(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, { + "results": [{"id": "row_abc"}] + }) + with _patch_requests(mock_req): + row = exp.find_existing_row("db_xyz", "sess1", 0) + assert row == "row_abc" + assert exp._cache["rows"]["sess1:0"] == "row_abc" + + def test_cache_hit_skips_query(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["rows"]["sess1:0"] = "cached_row" + + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "cached_row"}) + with _patch_requests(mock_req): + row = exp.find_existing_row("db_xyz", "sess1", 0) + # Only existence check (GET /pages/cached_row), no query + assert row == "cached_row" + assert mock_req.request.call_count == 1 + assert mock_req.request.call_args.args[0] == "GET" + + +class TestArchiveRowsForSession: + def test_archives_all_matching_rows(self, tmp_path): + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, { + "results": [{"id": "row_a"}, {"id": "row_b"}], + "has_more": False, + }), + _make_response(200, {"id": "row_a", "archived": True}), + _make_response(200, {"id": "row_b", "archived": True}), + ] + with _patch_requests(mock_req): + archived = exp.archive_rows_for_session("db_xyz", "sess1") + assert archived == 2 + # Verify the PATCH calls used archived=True + patch_calls = [c for c in mock_req.request.call_args_list + if c.args[0] == "PATCH"] + assert len(patch_calls) == 2 + for c in patch_calls: + assert c.kwargs["json"] == {"archived": True} + + +class TestCreateRow: + def test_posts_to_pages_endpoint(self, tmp_path): + exp = _make_exporter() + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "new_row"}) + + properties = {"Name": {"title": [{"text": {"content": "task"}}]}} + body_blocks = [{"object": "block", "type": "paragraph", + "paragraph": {"rich_text": [{"text": {"content": "x"}}]}}] + + with _patch_requests(mock_req): + row_id = exp.create_row("db_xyz", properties, body_blocks) + + assert row_id == "new_row" + call_args = mock_req.request.call_args + assert call_args.args[0] == "POST" + # URL ends with /pages + assert call_args.args[1].endswith("/pages") + body = call_args.kwargs["json"] + assert body["parent"]["database_id"] == "db_xyz" + assert body["properties"] == properties + assert body["children"] == body_blocks + + +class TestRequestsMissing: + def test_friendly_error_when_requests_not_installed(self): + exp = _make_exporter() + import builtins + original_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "requests": + raise ImportError("No module named 'requests'") + return original_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=fake_import): + with pytest.raises(RuntimeError, match="requests"): + exp._request("GET", "/anything") From 9c07222f911adefddde0e7003185c6d07515738e Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 26 May 2026 18:02:06 +0900 Subject: [PATCH 04/30] feat: notion-push CLI + Notion blocks formatter (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli/notion_push.py: JSON read → entry_data → push pipeline - 멱등성: Session ID + Task Index로 skip - --force: 같은 session_id 행 archive 후 재push - 에러 분기: NotionAuthError fail fast, NotionBadRequest skip+continue - 실패 시 임시 JSON 보존, 성공 시 자동 삭제 - env vars (CLAUDE_DIARY_NOTION_TOKEN/ROOT_PAGE_ID) 우선 - formatter.py: build_notion_blocks() — Notion API block list 빌더 (body_intro paragraph + 사용자요청/파일/명령/Git/에러 섹션, 2000자 truncate) - git_info.py: task별 branch 결정 함수 4개 추가 (get_branch_for_commit, get_head_branch, get_commit_info, get_diff_stat_for_commits) - cli/__init__.py: `notion ` 그룹 명령 추가 (push 동작, init은 Phase 3) - 새 테스트 44개 (전체 521 통과) Co-Authored-By: Claude Opus 4.7 --- src/claude_diary/cli/__init__.py | 19 ++ src/claude_diary/cli/notion_push.py | 254 +++++++++++++++++++ src/claude_diary/formatter.py | 113 +++++++++ src/claude_diary/lib/git_info.py | 94 +++++++ tests/test_formatter.py | 84 ++++++- tests/test_git_info.py | 83 +++++++ tests/test_notion_push.py | 363 ++++++++++++++++++++++++++++ 7 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 src/claude_diary/cli/notion_push.py create mode 100644 tests/test_notion_push.py diff --git a/src/claude_diary/cli/__init__.py b/src/claude_diary/cli/__init__.py index b8e907c..c24011b 100644 --- a/src/claude_diary/cli/__init__.py +++ b/src/claude_diary/cli/__init__.py @@ -20,6 +20,17 @@ from claude_diary.cli.maintenance import cmd_reindex, cmd_audit, cmd_delete, cmd_dashboard from claude_diary.cli.setup import cmd_install, cmd_uninstall from claude_diary.cli.write import cmd_write +from claude_diary.cli.notion_push import cmd_notion_push + + +def cmd_notion(args): + """Dispatch `notion ` to the right command (Phase 3 fills in init).""" + if args.action == "push": + cmd_notion_push(args) + elif args.action == "init": + print("[claude-diary] `notion init` not yet implemented (Phase 3).", + file=sys.stderr) + sys.exit(2) def main(): @@ -113,6 +124,13 @@ def main(): # write (manual diary — for /diary slash command) sub.add_parser("write", help="Write current session diary to ///") + # notion (hierarchical Notion DB integration — for /diary-notion slash command) + p_notion = sub.add_parser("notion", help="Notion hierarchical DB integration") + p_notion.add_argument("action", choices=["init", "push"], help="Action to perform") + p_notion.add_argument("--input", help="JSON input file (push only)") + p_notion.add_argument("--force", action="store_true", + help="Archive prior rows for the session before pushing (push only)") + args = parser.parse_args() if args.command is None: @@ -136,6 +154,7 @@ def main(): "install": cmd_install, "uninstall": cmd_uninstall, "write": cmd_write, + "notion": cmd_notion, } fn = commands.get(args.command) diff --git a/src/claude_diary/cli/notion_push.py b/src/claude_diary/cli/notion_push.py new file mode 100644 index 0000000..7572b14 --- /dev/null +++ b/src/claude_diary/cli/notion_push.py @@ -0,0 +1,254 @@ +"""`claude-diary notion-push` — push tasks JSON to the Notion hierarchical DB. + +Driven by the `/diary-notion` slash command: + 1. Claude writes `.diary-notion-.json` in cwd + 2. This CLI reads it, resolves git info, and pushes each task as a DB row + 3. On success the temp file is deleted; on partial failure it is preserved + so the user can re-push with `--force` + +Idempotency: each row carries hidden Session ID + Task Index columns. Re-runs +skip already-pushed rows. `--force` first archives prior rows for the session, +then re-pushes everything. +""" + +import json +import os +import sys +from datetime import datetime, timezone, timedelta + +from claude_diary.config import load_config +from claude_diary.log import get_logger, configure_from_config +from claude_diary.formatter import build_notion_blocks +from claude_diary.lib import notion_cache +from claude_diary.lib.git_info import ( + get_branch_for_commit, + get_head_branch, + get_commit_info, + get_diff_stat_for_commits, +) +from claude_diary.exporters.notion_hierarchical import ( + NotionHierarchicalExporter, + NotionAuthError, + NotionBadRequest, + NotionNotFound, +) + +logger = get_logger("claude_diary.cli.notion_push") + + +def cmd_notion_push(args): + """Read tasks JSON and push each task as a row to the Notion DB.""" + config = load_config() + configure_from_config(config) + + token, root_page_id = _resolve_credentials(config) + if not token or not root_page_id: + _print_setup_hint() + sys.exit(1) + + input_path = args.input + data = _read_json(input_path) + if data is None: + sys.exit(1) + + session_id = data.get("session_id") or _fallback_session_id() + tasks = data.get("tasks") or [] + if not tasks: + print("[claude-diary notion-push] No tasks to push.") + _cleanup(input_path) + return + + cwd = os.getcwd() + lang = config.get("lang", "ko") + tz_offset = config.get("timezone_offset", 9) + local_tz = timezone(timedelta(hours=tz_offset)) + today = datetime.now(local_tz) + year = today.year + date_str = today.strftime("%Y-%m-%d") + + exporter = NotionHierarchicalExporter({ + "api_token": token, + "root_page_id": root_page_id, + }) + exporter.load_cache() + + if args.force: + try: + db_id = exporter.ensure_database(year) + archived = exporter.archive_rows_for_session(db_id, session_id) + print("[claude-diary notion-push] --force: archived %d existing row(s)" % archived) + except NotionAuthError as e: + print("[claude-diary notion-push] Auth error: %s" % e, file=sys.stderr) + print(" Check: claude-diary config or run `claude-diary notion init`", file=sys.stderr) + sys.exit(1) + + results = {"pushed": [], "skipped": [], "failed": []} + auth_failed = False + + for idx, task in enumerate(tasks): + title = task.get("title") or "(untitled)" + if auth_failed: + results["failed"].append((idx, title, "skipped after earlier auth error")) + continue + try: + outcome = _push_task( + exporter, year, date_str, session_id, idx, task, cwd, lang + ) + if outcome == "pushed": + results["pushed"].append((idx, title)) + else: + results["skipped"].append((idx, title, "already exists")) + except NotionAuthError as e: + auth_failed = True + results["failed"].append((idx, title, "auth: %s" % e)) + except NotionBadRequest as e: + results["failed"].append((idx, title, "bad request: %s" % e)) + except Exception as e: + results["failed"].append((idx, title, str(e))) + + exporter.save_cache() + + _print_report(results, input_path) + + if not results["failed"]: + _cleanup(input_path) + + sys.exit(1 if auth_failed else 0) + + +def _resolve_credentials(config): + """Resolve token and root_page_id from env vars first, then config.""" + notion_cfg = (config.get("exporters") or {}).get("notion_hierarchical") or {} + token = ( + os.environ.get("CLAUDE_DIARY_NOTION_TOKEN") + or notion_cfg.get("api_token") + ) + root_page_id = ( + os.environ.get("CLAUDE_DIARY_NOTION_ROOT_PAGE_ID") + or notion_cfg.get("root_page_id") + ) + return token, root_page_id + + +def _read_json(path): + if not path or not os.path.exists(path): + print("[claude-diary notion-push] Input file not found: %s" % path, file=sys.stderr) + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + print("[claude-diary notion-push] Failed to read JSON: %s" % e, file=sys.stderr) + return None + + +def _fallback_session_id(): + import time + return "manual-%d" % int(time.time()) + + +def _push_task(exporter, year, date_str, session_id, task_index, task, cwd, lang): + """Push one task to Notion. Returns 'pushed' or 'skipped'.""" + db_id = exporter.ensure_database(year) + + existing = exporter.find_existing_row(db_id, session_id, task_index) + if existing: + return "skipped" + + git_info = _gather_git_info(cwd, task.get("commit_hashes") or []) + branch = git_info.get("branch") or "" + + properties = _build_properties( + task, date_str, branch, git_info, session_id, task_index + ) + body_blocks = build_notion_blocks(task, git_info, lang) + + row_id = exporter.create_row(db_id, properties, body_blocks) + notion_cache.set_row(exporter._cache, session_id, task_index, row_id) + return "pushed" + + +def _gather_git_info(cwd, commit_hashes): + """Resolve branch + commits + diff_stat for a task.""" + info = {"branch": "", "commits": [], "diff_stat": {"added": 0, "deleted": 0, "files": 0}} + if commit_hashes: + info["branch"] = get_branch_for_commit(cwd, commit_hashes[0]) + for h in commit_hashes: + c = get_commit_info(cwd, h) + if c: + info["commits"].append(c) + info["diff_stat"] = get_diff_stat_for_commits(cwd, commit_hashes) + else: + info["branch"] = get_head_branch(cwd) + return info + + +def _build_properties(task, date_str, branch, git_info, session_id, task_index): + """Build Notion DB row properties from task data.""" + title = task.get("title") or "(untitled)" + project = (task.get("project") or "unknown").strip() or "unknown" + categories = [c for c in (task.get("categories") or []) if c] + stat = git_info.get("diff_stat") or {} + commits = git_info.get("commits") or [] + files_count = ( + len(task.get("files_modified") or []) + + len(task.get("files_created") or []) + ) + lines = (stat.get("added", 0) or 0) + (stat.get("deleted", 0) or 0) + + props = { + "Name": {"title": [{"text": {"content": title[:2000]}}]}, + "Date": {"date": {"start": date_str}}, + "Project": {"select": {"name": _safe_select(project)}}, + "Categories": { + "multi_select": [{"name": _safe_select(c)} for c in categories[:10]] + }, + "Files": {"number": files_count}, + "Commits": {"number": len(commits)}, + "Lines": {"number": lines}, + "Session ID": {"rich_text": [{"text": {"content": session_id[:2000]}}]}, + "Task Index": {"number": task_index}, + } + if branch: + props["Branch"] = {"select": {"name": _safe_select(branch)}} + return props + + +def _safe_select(name): + """Notion select names cannot contain commas. Replace with '-'.""" + return (name or "").replace(",", "-")[:100] or "unknown" + + +def _print_report(results, input_path): + pushed = len(results["pushed"]) + skipped = len(results["skipped"]) + failed = len(results["failed"]) + print("[claude-diary notion-push] Pushed %d, skipped %d, failed %d" % + (pushed, skipped, failed)) + for _, title in results["pushed"]: + print(" + %s" % title) + for _, title, reason in results["skipped"]: + print(" - %s (%s)" % (title, reason)) + for _, title, reason in results["failed"]: + print(" ! %s -- %s" % (title, reason)) + if failed > 0: + print() + print("Failed tasks preserved in: %s" % input_path) + print("Retry: claude-diary notion-push --input %s --force" % input_path) + + +def _cleanup(input_path): + if not input_path: + return + try: + os.remove(input_path) + except OSError: + pass + + +def _print_setup_hint(): + print("[claude-diary notion-push] Notion hierarchical exporter not configured.", + file=sys.stderr) + print(" Run: claude-diary notion init", file=sys.stderr) + print(" Or set CLAUDE_DIARY_NOTION_TOKEN and CLAUDE_DIARY_NOTION_ROOT_PAGE_ID", + file=sys.stderr) diff --git a/src/claude_diary/formatter.py b/src/claude_diary/formatter.py index 0a93255..7ae0546 100644 --- a/src/claude_diary/formatter.py +++ b/src/claude_diary/formatter.py @@ -115,6 +115,119 @@ def format_entry(entry_data, lang="ko"): return "\n".join(lines) +def build_notion_blocks(task, git_info=None, lang="ko"): + """Build Notion API block list for a task page body. + + Body layout: + [body_intro paragraph] + ## 사용자 요청 - prompts + ## 수정/생성 파일 - files (modified + created) + ## 실행한 명령 - commands (trivial filtered out) + ## Git 변경사항 - branch, commits, lines + ## 발생한 에러 - errors (only if any) + + Skipped sections render no heading (page doesn't get noisy with empty heads). + Long strings are truncated to RICH_TEXT_LIMIT (Notion rich_text caps at 2000). + """ + L = lambda key: get_label(key, lang) + blocks = [] + + intro = (task.get("body_intro") or "").strip() + if intro: + blocks.append(_paragraph(intro)) + + prompts = task.get("user_prompts") or [] + if prompts: + blocks.append(_heading(L("task_requests"))) + for p in prompts[:5]: + short = _truncate(p.replace("\n", " ").strip(), 500) + if short: + blocks.append(_bullet(short)) + + modified = task.get("files_modified") or [] + created = task.get("files_created") or [] + if modified or created: + blocks.append(_heading("%s / %s" % (L("files_modified"), L("files_created")))) + for f in modified[:15]: + blocks.append(_bullet(_truncate(f, 500))) + for f in created[:15]: + blocks.append(_bullet(_truncate("%s (+)" % f, 500))) + + commands = task.get("commands_run") or [] + trivial = {"ls", "pwd", "cat", "echo", "cd", "which", "type", "clear"} + sig = [] + for c in commands: + first = c.strip().split()[0] if c.strip() else "" + if first and first not in trivial: + sig.append(c) + sig = sig[:10] + if sig: + blocks.append(_heading(L("commands"))) + for c in sig: + blocks.append(_bullet(_truncate(c, 500))) + + if git_info: + branch = git_info.get("branch", "") + commits = git_info.get("commits", []) + stat = git_info.get("diff_stat") or {} + if branch or commits or stat.get("added") or stat.get("deleted"): + blocks.append(_heading(L("git"))) + if branch: + blocks.append(_paragraph("%s: %s" % (L("branch"), branch))) + for c in commits[:10]: + short_hash = c.get("short_hash") or (c.get("hash") or "")[:7] + msg = c.get("message", "") + blocks.append(_bullet(_truncate("%s %s" % (short_hash, msg), 500))) + if stat.get("added") or stat.get("deleted"): + blocks.append(_paragraph("+%d / -%d (%d files)" % ( + stat.get("added", 0), stat.get("deleted", 0), stat.get("files", 0) + ))) + + errors = task.get("errors") or [] + if errors: + blocks.append(_heading(L("issues"))) + for e in errors[:5]: + blocks.append(_bullet(_truncate(e, 500))) + + return blocks + + +def _paragraph(text): + return { + "object": "block", + "type": "paragraph", + "paragraph": {"rich_text": _rich_text(text)}, + } + + +def _heading(text): + return { + "object": "block", + "type": "heading_2", + "heading_2": {"rich_text": _rich_text(text)}, + } + + +def _bullet(text): + return { + "object": "block", + "type": "bulleted_list_item", + "bulleted_list_item": {"rich_text": _rich_text(text)}, + } + + +def _rich_text(text): + return [{"type": "text", "text": {"content": _truncate(text, 2000)}}] + + +def _truncate(text, n): + if not text: + return "" + if len(text) <= n: + return text + return text[: n - 1] + "…" + + def format_daily_header(date_str, lang="ko"): """Create daily diary file header.""" L = lambda key: get_label(key, lang) diff --git a/src/claude_diary/lib/git_info.py b/src/claude_diary/lib/git_info.py index 9179a38..08cd235 100644 --- a/src/claude_diary/lib/git_info.py +++ b/src/claude_diary/lib/git_info.py @@ -80,6 +80,100 @@ def _get_recent_commits(cwd, since=None): return [] +def get_branch_for_commit(cwd, commit_hash): + """Return the (first) branch containing the given commit. + + Used by /diary-notion to label each task with its branch. Falls back to + HEAD branch when `git branch --contains` returns nothing (detached HEAD, + commit not in any branch, etc). + """ + if not cwd or not commit_hash: + return get_head_branch(cwd) + try: + result = subprocess.run( + ["git", "branch", "--contains", commit_hash, "--format=%(refname:short)"], + cwd=cwd, capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.strip().split("\n"): + line = line.strip().lstrip("* ").strip() + if line and not line.startswith("("): + return line + except Exception: + pass + return get_head_branch(cwd) + + +def get_head_branch(cwd): + """Return current HEAD branch, or empty string if detached/unknown.""" + if not cwd: + return "" + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=cwd, capture_output=True, text=True, timeout=5 + ) + branch = result.stdout.strip() + if branch and branch != "HEAD": + return branch + except Exception: + pass + return "" + + +def get_commit_info(cwd, commit_hash): + """Return {hash, short_hash, message} for a commit, or None if not found.""" + if not cwd or not commit_hash: + return None + try: + result = subprocess.run( + ["git", "log", "-n", "1", "--format=%H%x09%h%x09%s", commit_hash], + cwd=cwd, capture_output=True, text=True, timeout=5 + ) + line = result.stdout.strip() + if not line: + return None + parts = line.split("\t") + if len(parts) < 3: + return None + return {"hash": parts[0], "short_hash": parts[1], "message": parts[2]} + except Exception: + return None + + +def get_diff_stat_for_commits(cwd, commit_hashes): + """Sum diff stats across the given commits. + + Returns {"added": int, "deleted": int, "files": int}. Files count is + a sum-of-touched (may double-count if a file changed in multiple commits). + """ + total = {"added": 0, "deleted": 0, "files": 0} + if not cwd or not commit_hashes: + return total + import re + for h in commit_hashes: + try: + result = subprocess.run( + ["git", "show", "--stat", "--format=", h], + cwd=cwd, capture_output=True, text=True, timeout=5 + ) + lines = result.stdout.strip().split("\n") + if not lines: + continue + summary = lines[-1] + f = re.search(r'(\d+) files? changed', summary) + a = re.search(r'(\d+) insertions?', summary) + d = re.search(r'(\d+) deletions?', summary) + if f: + total["files"] += int(f.group(1)) + if a: + total["added"] += int(a.group(1)) + if d: + total["deleted"] += int(d.group(1)) + except Exception: + continue + return total + + def get_diff_stat(cwd, since=None): """Get diff stat (added/deleted lines, files changed). diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 1a5546e..175cd74 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -1,6 +1,6 @@ """Tests for markdown formatter.""" -from claude_diary.formatter import format_entry, format_daily_header +from claude_diary.formatter import format_entry, format_daily_header, build_notion_blocks class TestFormatEntry: @@ -95,3 +95,85 @@ def test_english_header(self): def test_invalid_date(self): header = format_daily_header("invalid", lang="ko") assert "작업일지" in header + + +def _block_text(block): + """Helper: extract content string from a Notion block.""" + btype = block["type"] + rt = block[btype]["rich_text"] + return "".join(r["text"]["content"] for r in rt) + + +class TestBuildNotionBlocks: + def test_empty_task_returns_empty(self): + blocks = build_notion_blocks({}) + assert blocks == [] + + def test_body_intro_becomes_first_paragraph(self): + blocks = build_notion_blocks({"body_intro": "한 줄 요약."}) + assert blocks[0]["type"] == "paragraph" + assert _block_text(blocks[0]) == "한 줄 요약." + + def test_user_prompts_section(self): + blocks = build_notion_blocks({"user_prompts": ["첫 요청", "두 번째 요청"]}) + # heading_2 + 2 bullets + assert blocks[0]["type"] == "heading_2" + assert "작업 요청" in _block_text(blocks[0]) + assert blocks[1]["type"] == "bulleted_list_item" + assert _block_text(blocks[1]) == "첫 요청" + assert _block_text(blocks[2]) == "두 번째 요청" + + def test_files_section_marks_created_with_plus(self): + blocks = build_notion_blocks({ + "files_modified": ["src/main.py"], + "files_created": ["src/new.py"], + }) + texts = [_block_text(b) for b in blocks if b["type"] == "bulleted_list_item"] + assert "src/main.py" in texts + assert any("src/new.py" in t and "(+)" in t for t in texts) + + def test_trivial_commands_filtered_out(self): + blocks = build_notion_blocks({ + "commands_run": ["ls", "pwd", "npm test", "git status"], + }) + bullets = [_block_text(b) for b in blocks if b["type"] == "bulleted_list_item"] + assert "npm test" in bullets + assert "git status" in bullets + assert "ls" not in bullets + assert "pwd" not in bullets + + def test_git_section_with_branch_and_commits(self): + git_info = { + "branch": "feat/diary-notion", + "commits": [ + {"hash": "abc1234ef", "short_hash": "abc1234", "message": "feat: x"}, + ], + "diff_stat": {"added": 42, "deleted": 8, "files": 3}, + } + blocks = build_notion_blocks({"title": "t"}, git_info=git_info) + text = "\n".join(_block_text(b) for b in blocks) + assert "feat/diary-notion" in text + assert "abc1234" in text + assert "feat: x" in text + assert "+42 / -8" in text + + def test_errors_section_only_when_present(self): + # no errors → no "issues" heading + blocks_clean = build_notion_blocks({"title": "t"}) + for b in blocks_clean: + if b["type"] == "heading_2": + assert "이슈" not in _block_text(b) + + blocks_dirty = build_notion_blocks({"errors": ["ImportError: requests"]}) + headings = [_block_text(b) for b in blocks_dirty if b["type"] == "heading_2"] + assert any("이슈" in h for h in headings) + + def test_long_text_truncated_below_2000(self): + long_intro = "x" * 5000 + blocks = build_notion_blocks({"body_intro": long_intro}) + content = blocks[0]["paragraph"]["rich_text"][0]["text"]["content"] + assert len(content) <= 2000 + + def test_english_labels(self): + blocks = build_notion_blocks({"user_prompts": ["hello"]}, lang="en") + assert _block_text(blocks[0]) == "Task Requests" diff --git a/tests/test_git_info.py b/tests/test_git_info.py index 59c0c03..19dfdca 100644 --- a/tests/test_git_info.py +++ b/tests/test_git_info.py @@ -6,6 +6,10 @@ from claude_diary.lib.git_info import ( collect_git_info, get_diff_stat, + get_branch_for_commit, + get_head_branch, + get_commit_info, + get_diff_stat_for_commits, _is_git_repo, _get_branch, _get_recent_commits, @@ -218,3 +222,82 @@ def fake_run(cmd, **kwargs): result = collect_git_info("/repo", session_start="2026-03-17T09:00:00Z") assert result is not None assert result["branch"] == "dev" + + +class TestGetBranchForCommit: + def test_returns_first_branch(self): + mock_result = MagicMock(stdout="feat/diary-notion\nmain\n") + with patch("claude_diary.lib.git_info.subprocess.run", return_value=mock_result): + assert get_branch_for_commit("/repo", "abc1234") == "feat/diary-notion" + + def test_empty_falls_back_to_head(self): + # First call: branch --contains returns nothing + # Second call (from get_head_branch): rev-parse returns "main" + outputs = [MagicMock(stdout=""), MagicMock(stdout="main\n")] + with patch("claude_diary.lib.git_info.subprocess.run", side_effect=outputs): + assert get_branch_for_commit("/repo", "abc1234") == "main" + + def test_no_commit_hash_returns_head(self): + with patch("claude_diary.lib.git_info.subprocess.run", + return_value=MagicMock(stdout="main\n")): + assert get_branch_for_commit("/repo", None) == "main" + + +class TestGetHeadBranch: + def test_returns_branch(self): + with patch("claude_diary.lib.git_info.subprocess.run", + return_value=MagicMock(stdout="main\n")): + assert get_head_branch("/repo") == "main" + + def test_detached_head_returns_empty(self): + with patch("claude_diary.lib.git_info.subprocess.run", + return_value=MagicMock(stdout="HEAD\n")): + assert get_head_branch("/repo") == "" + + def test_empty_cwd_returns_empty(self): + assert get_head_branch("") == "" + + def test_exception_returns_empty(self): + with patch("claude_diary.lib.git_info.subprocess.run", + side_effect=Exception("boom")): + assert get_head_branch("/repo") == "" + + +class TestGetCommitInfo: + def test_returns_parsed_fields(self): + out = "abc1234deadbeef\tabc1234\tfeat: add login\n" + with patch("claude_diary.lib.git_info.subprocess.run", + return_value=MagicMock(stdout=out)): + info = get_commit_info("/repo", "abc1234") + assert info["hash"] == "abc1234deadbeef" + assert info["short_hash"] == "abc1234" + assert info["message"] == "feat: add login" + + def test_empty_returns_none(self): + with patch("claude_diary.lib.git_info.subprocess.run", + return_value=MagicMock(stdout="")): + assert get_commit_info("/repo", "abc1234") is None + + def test_no_inputs_returns_none(self): + assert get_commit_info(None, "abc1234") is None + assert get_commit_info("/repo", None) is None + + +class TestGetDiffStatForCommits: + def test_sums_across_commits(self): + def fake_run(cmd, **kwargs): + if "abc1" in cmd: + return MagicMock(stdout="\n 2 files changed, 10 insertions(+), 3 deletions(-)\n") + if "def5" in cmd: + return MagicMock(stdout="\n 1 file changed, 5 insertions(+), 2 deletions(-)\n") + return MagicMock(stdout="") + + with patch("claude_diary.lib.git_info.subprocess.run", side_effect=fake_run): + total = get_diff_stat_for_commits("/repo", ["abc1", "def5"]) + assert total["files"] == 3 + assert total["added"] == 15 + assert total["deleted"] == 5 + + def test_empty_list_returns_zeros(self): + total = get_diff_stat_for_commits("/repo", []) + assert total == {"added": 0, "deleted": 0, "files": 0} diff --git a/tests/test_notion_push.py b/tests/test_notion_push.py new file mode 100644 index 0000000..1d1e2b9 --- /dev/null +++ b/tests/test_notion_push.py @@ -0,0 +1,363 @@ +"""Tests for `claude-diary notion push` CLI.""" + +import json +import os +from unittest.mock import patch, MagicMock + +import pytest + +from claude_diary.cli.notion_push import ( + cmd_notion_push, + _resolve_credentials, + _safe_select, + _build_properties, + _gather_git_info, + _read_json, +) +from claude_diary.exporters.notion_hierarchical import ( + NotionAuthError, + NotionBadRequest, +) + + +class TestResolveCredentials: + def test_env_vars_take_priority(self): + config = { + "exporters": { + "notion_hierarchical": { + "api_token": "config_token", + "root_page_id": "config_page", + } + } + } + with patch.dict(os.environ, { + "CLAUDE_DIARY_NOTION_TOKEN": "env_token", + "CLAUDE_DIARY_NOTION_ROOT_PAGE_ID": "env_page", + }): + token, page = _resolve_credentials(config) + assert token == "env_token" + assert page == "env_page" + + def test_config_used_when_no_env(self): + config = { + "exporters": { + "notion_hierarchical": { + "api_token": "config_token", + "root_page_id": "config_page", + } + } + } + with patch.dict(os.environ, {}, clear=True): + token, page = _resolve_credentials(config) + assert token == "config_token" + assert page == "config_page" + + def test_missing_returns_none(self): + with patch.dict(os.environ, {}, clear=True): + token, page = _resolve_credentials({"exporters": {}}) + assert token is None + assert page is None + + +class TestSafeSelect: + def test_replaces_comma(self): + assert _safe_select("foo, bar") == "foo- bar" + + def test_truncates_to_100(self): + assert len(_safe_select("x" * 200)) == 100 + + def test_empty_returns_unknown(self): + assert _safe_select("") == "unknown" + assert _safe_select(None) == "unknown" + + +class TestBuildProperties: + def _base_task(self): + return { + "title": "DB 결정", + "project": "diary", + "categories": ["design"], + "files_modified": ["a.py"], + "files_created": ["b.py"], + } + + def test_all_columns_present(self): + task = self._base_task() + git_info = { + "branch": "feat/x", + "commits": [{"hash": "abc", "short_hash": "abc", "message": "m"}], + "diff_stat": {"added": 10, "deleted": 5, "files": 2}, + } + props = _build_properties(task, "2026-05-26", "feat/x", git_info, "sess1", 0) + assert props["Name"]["title"][0]["text"]["content"] == "DB 결정" + assert props["Date"]["date"]["start"] == "2026-05-26" + assert props["Project"]["select"]["name"] == "diary" + assert props["Branch"]["select"]["name"] == "feat/x" + assert props["Categories"]["multi_select"][0]["name"] == "design" + assert props["Files"]["number"] == 2 + assert props["Commits"]["number"] == 1 + assert props["Lines"]["number"] == 15 + assert props["Session ID"]["rich_text"][0]["text"]["content"] == "sess1" + assert props["Task Index"]["number"] == 0 + + def test_branch_omitted_when_empty(self): + task = self._base_task() + props = _build_properties(task, "2026-05-26", "", {}, "sess1", 0) + assert "Branch" not in props + + def test_empty_categories_filtered(self): + task = self._base_task() + task["categories"] = ["", "design", None, " "] + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + cats = props["Categories"]["multi_select"] + # empty/None filtered, whitespace kept after _safe_select but not stripped here + names = [c["name"] for c in cats] + assert "design" in names + + def test_no_commits_zeros(self): + task = self._base_task() + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert props["Commits"]["number"] == 0 + assert props["Lines"]["number"] == 0 + + +class TestGatherGitInfo: + def test_with_commit_hashes(self): + with patch("claude_diary.cli.notion_push.get_branch_for_commit", + return_value="feat/x"), \ + patch("claude_diary.cli.notion_push.get_commit_info", + side_effect=[{"hash": "a", "short_hash": "a", "message": "m"}]), \ + patch("claude_diary.cli.notion_push.get_diff_stat_for_commits", + return_value={"added": 5, "deleted": 2, "files": 1}): + info = _gather_git_info("/repo", ["abc1234"]) + assert info["branch"] == "feat/x" + assert len(info["commits"]) == 1 + assert info["diff_stat"]["added"] == 5 + + def test_no_commits_uses_head(self): + with patch("claude_diary.cli.notion_push.get_head_branch", + return_value="main"): + info = _gather_git_info("/repo", []) + assert info["branch"] == "main" + assert info["commits"] == [] + assert info["diff_stat"] == {"added": 0, "deleted": 0, "files": 0} + + +class TestReadJson: + def test_missing_file_returns_none(self, tmp_path): + assert _read_json(str(tmp_path / "nonexistent.json")) is None + + def test_invalid_json_returns_none(self, tmp_path): + p = tmp_path / "bad.json" + p.write_text("{{{ not json", encoding="utf-8") + assert _read_json(str(p)) is None + + def test_valid_json_parsed(self, tmp_path): + p = tmp_path / "good.json" + p.write_text('{"session_id":"s1","tasks":[]}', encoding="utf-8") + data = _read_json(str(p)) + assert data["session_id"] == "s1" + + +# ── End-to-end cmd_notion_push tests ────────────────────────────────────── + +def _make_args(input_path, force=False): + args = MagicMock() + args.input = input_path + args.force = force + return args + + +def _write_json(path, data): + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + + +class TestCmdNotionPush: + def test_missing_credentials_exits(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), {"session_id": "s1", "tasks": [{"title": "t"}]}) + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value={}), \ + pytest.raises(SystemExit) as exc: + cmd_notion_push(_make_args(str(input_path))) + assert exc.value.code == 1 + + def test_missing_input_file_exits(self): + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + pytest.raises(SystemExit) as exc: + cmd_notion_push(_make_args("/nonexistent/file.json")) + assert exc.value.code == 1 + + def test_empty_tasks_cleans_up(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), {"session_id": "s1", "tasks": []}) + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config): + cmd_notion_push(_make_args(str(input_path))) + # File deleted after empty-tasks no-op + assert not input_path.exists() + + def test_successful_push_cleans_up(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "tasks": [{"title": "task A", "project": "diary"}], + }) + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + + mock_exp = MagicMock() + mock_exp.ensure_database.return_value = "db_xyz" + mock_exp.find_existing_row.return_value = None + mock_exp.create_row.return_value = "row_abc" + mock_exp._cache = {"rows": {}, "years": {}, "databases": {}, "root_page_id": "p"} + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter", + return_value=mock_exp), \ + patch("claude_diary.cli.notion_push.get_head_branch", return_value="main"), \ + pytest.raises(SystemExit) as exc: + cmd_notion_push(_make_args(str(input_path))) + + assert exc.value.code == 0 + mock_exp.create_row.assert_called_once() + assert not input_path.exists() + + def test_skip_existing_row(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "tasks": [{"title": "task A", "project": "diary"}], + }) + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + + mock_exp = MagicMock() + mock_exp.ensure_database.return_value = "db_xyz" + mock_exp.find_existing_row.return_value = "existing_row_id" # exists! + mock_exp._cache = {"rows": {}, "years": {}, "databases": {}, "root_page_id": "p"} + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter", + return_value=mock_exp), \ + pytest.raises(SystemExit): + cmd_notion_push(_make_args(str(input_path))) + + # No new row created + mock_exp.create_row.assert_not_called() + + def test_force_archives_then_pushes(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "tasks": [{"title": "task A", "project": "diary"}], + }) + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + + mock_exp = MagicMock() + mock_exp.ensure_database.return_value = "db_xyz" + mock_exp.archive_rows_for_session.return_value = 2 + mock_exp.find_existing_row.return_value = None + mock_exp.create_row.return_value = "row_new" + mock_exp._cache = {"rows": {}, "years": {}, "databases": {}, "root_page_id": "p"} + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter", + return_value=mock_exp), \ + patch("claude_diary.cli.notion_push.get_head_branch", return_value="main"), \ + pytest.raises(SystemExit): + cmd_notion_push(_make_args(str(input_path), force=True)) + + mock_exp.archive_rows_for_session.assert_called_once() + mock_exp.create_row.assert_called_once() + + def test_auth_error_aborts_remaining_tasks(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "tasks": [ + {"title": "A"}, {"title": "B"}, {"title": "C"}, + ], + }) + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + + mock_exp = MagicMock() + # First task triggers auth error + mock_exp.ensure_database.side_effect = NotionAuthError("unauthorized") + mock_exp._cache = {"rows": {}, "years": {}, "databases": {}, "root_page_id": "p"} + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter", + return_value=mock_exp), \ + pytest.raises(SystemExit) as exc: + cmd_notion_push(_make_args(str(input_path))) + + # Exits 1 due to auth error + assert exc.value.code == 1 + # JSON file preserved for retry + assert input_path.exists() + + def test_bad_request_skips_one_task(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "tasks": [{"title": "A"}, {"title": "B"}], + }) + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + + mock_exp = MagicMock() + mock_exp.ensure_database.return_value = "db" + mock_exp.find_existing_row.return_value = None + mock_exp.create_row.side_effect = [ + NotionBadRequest("malformed"), # A fails + "row_b_id", # B succeeds + ] + mock_exp._cache = {"rows": {}, "years": {}, "databases": {}, "root_page_id": "p"} + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter", + return_value=mock_exp), \ + patch("claude_diary.cli.notion_push.get_head_branch", return_value="main"), \ + pytest.raises(SystemExit): + cmd_notion_push(_make_args(str(input_path))) + + # B was still attempted after A failed + assert mock_exp.create_row.call_count == 2 + # File preserved because of partial failure + assert input_path.exists() From 90e4f2230081ccd18eb40cf67e0b0701f75f2e0a Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 26 May 2026 18:04:04 +0900 Subject: [PATCH 05/30] =?UTF-8?q?feat:=20notion=20init=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=ED=98=95=20=EC=85=8B=EC=97=85=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli/notion_init.py: claude-diary notion init - getpass로 token 가려서 입력 + URL/ID 받기 - parse_page_id(): URL/대시UUID/평탄 hex 모두 지원 → canonical 32hex - 권한 검증: GET /users/me (token) + GET /blocks/{id} (페이지 접근) - 친절한 에러: 401 → token 발급 URL, 404 → Connections 공유 안내 - 검증 성공 시 exporters.notion_hierarchical에 저장 - cli/__init__.py: notion init 호출 활성화 - 새 테스트 17개 (전체 538 통과) Co-Authored-By: Claude Opus 4.7 --- src/claude_diary/cli/__init__.py | 7 +- src/claude_diary/cli/notion_init.py | 148 +++++++++++++++++++++++++ tests/test_notion_init.py | 161 ++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 src/claude_diary/cli/notion_init.py create mode 100644 tests/test_notion_init.py diff --git a/src/claude_diary/cli/__init__.py b/src/claude_diary/cli/__init__.py index c24011b..44a60f8 100644 --- a/src/claude_diary/cli/__init__.py +++ b/src/claude_diary/cli/__init__.py @@ -21,16 +21,15 @@ from claude_diary.cli.setup import cmd_install, cmd_uninstall from claude_diary.cli.write import cmd_write from claude_diary.cli.notion_push import cmd_notion_push +from claude_diary.cli.notion_init import cmd_notion_init def cmd_notion(args): - """Dispatch `notion ` to the right command (Phase 3 fills in init).""" + """Dispatch `notion ` to the right command.""" if args.action == "push": cmd_notion_push(args) elif args.action == "init": - print("[claude-diary] `notion init` not yet implemented (Phase 3).", - file=sys.stderr) - sys.exit(2) + cmd_notion_init(args) def main(): diff --git a/src/claude_diary/cli/notion_init.py b/src/claude_diary/cli/notion_init.py new file mode 100644 index 0000000..af6e155 --- /dev/null +++ b/src/claude_diary/cli/notion_init.py @@ -0,0 +1,148 @@ +"""`claude-diary notion init` — interactive Notion setup. + +Walks the user through: + 1. Pasting their integration token (https://www.notion.so/my-integrations) + 2. Pasting the root page URL or ID + 3. Verifying token + read access via the Notion API + 4. Saving credentials to /config.json under + exporters.notion_hierarchical + +Write permission is NOT verified at init time — it follows from read access +when the user shares the page with their integration. If the first push 404s, +the error message points them back to the share dialog. +""" + +import getpass +import re +import sys + +from claude_diary.config import load_config, save_config, get_config_path +from claude_diary.exporters.notion_hierarchical import ( + NotionHierarchicalExporter, + NotionAuthError, + NotionNotFound, +) + + +PAGE_ID_RE = re.compile( + r"([0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12})" +) + + +def cmd_notion_init(args): + """Run the interactive setup. Returns 0 on success, non-zero on failure.""" + print("─" * 56) + print("Notion hierarchical export setup") + print("─" * 56) + + token = _prompt_token() + if not token: + print("\nAborted (no token).", file=sys.stderr) + sys.exit(1) + + page_input = _prompt_page() + if not page_input: + print("\nAborted (no page URL/ID).", file=sys.stderr) + sys.exit(1) + + page_id = parse_page_id(page_input) + if not page_id: + print( + "\n[claude-diary] Could not parse a page ID from input.\n" + " Expected: a Notion URL or a 32-character page ID.", + file=sys.stderr, + ) + sys.exit(1) + print(" Parsed page_id: %s" % page_id) + + print("\nStep 3/3: Verifying access...") + ok, error = _verify_access(token, page_id) + if not ok: + print(" %s" % error, file=sys.stderr) + sys.exit(1) + + _save_credentials(token, page_id) + print("\nSaved to: %s" % get_config_path()) + print(" exporters.notion_hierarchical.api_token = secret_***") + print(" exporters.notion_hierarchical.root_page_id = %s" % page_id) + print("\nTry it: type /diary-notion in any Claude Code session.") + + +def parse_page_id(input_str): + """Extract a Notion page ID from a URL or raw ID string. + + Accepts the 8-4-4-4-12 UUID form (with or without dashes). Returns the + canonical undashed 32-char hex, or None on parse failure. + """ + if not input_str: + return None + m = PAGE_ID_RE.search(input_str.strip()) + if not m: + return None + return m.group(1).replace("-", "").lower() + + +def _prompt_token(): + print("\nStep 1/3: Integration token") + print(" Get it from: https://www.notion.so/my-integrations") + try: + return getpass.getpass(" Token (secret_...): ").strip() + except (EOFError, KeyboardInterrupt): + return "" + + +def _prompt_page(): + print("\nStep 2/3: Root page URL or ID") + print(" Paste a Notion URL or page ID:") + try: + return input(" > ").strip() + except (EOFError, KeyboardInterrupt): + return "" + + +def _verify_access(token, page_id): + """Verify token is valid and the integration can read the root page. + + Returns (ok: bool, error_message: str). + """ + exporter = NotionHierarchicalExporter({ + "api_token": token, + "root_page_id": page_id, + }) + try: + exporter._request("GET", "/users/me") + except NotionAuthError: + return False, ( + "✗ Token invalid (401/403).\n" + " Get a new token at https://www.notion.so/my-integrations" + ) + except Exception as e: + return False, "✗ Could not verify token: %s" % e + print(" ✓ Token valid") + + try: + exporter._request("GET", "/blocks/%s" % page_id) + except NotionNotFound: + return False, ( + "✗ Page not found, or not shared with the integration.\n" + " In Notion: open the page → top-right ⋯ → Connections → add your integration." + ) + except NotionAuthError: + return False, ( + "✗ Page exists but the integration has no access.\n" + " In Notion: open the page → top-right ⋯ → Connections → add your integration." + ) + except Exception as e: + return False, "✗ Could not verify page access: %s" % e + print(" ✓ Integration can read root page") + return True, "" + + +def _save_credentials(token, page_id): + """Merge token + root_page_id into config.json under notion_hierarchical.""" + config = load_config() + exporters = config.setdefault("exporters", {}) + nh = exporters.setdefault("notion_hierarchical", {}) + nh["api_token"] = token + nh["root_page_id"] = page_id + save_config(config) diff --git a/tests/test_notion_init.py b/tests/test_notion_init.py new file mode 100644 index 0000000..0867835 --- /dev/null +++ b/tests/test_notion_init.py @@ -0,0 +1,161 @@ +"""Tests for `claude-diary notion init` interactive setup.""" + +import json +from unittest.mock import patch, MagicMock + +import pytest + +from claude_diary.cli.notion_init import ( + cmd_notion_init, + parse_page_id, + _verify_access, + _save_credentials, +) +from claude_diary.exporters.notion_hierarchical import ( + NotionAuthError, + NotionNotFound, +) + + +class TestParsePageId: + def test_plain_undashed_32_hex(self): + s = "1234567890abcdef1234567890abcdef" + assert parse_page_id(s) == s + + def test_dashed_uuid_form(self): + assert parse_page_id("12345678-90ab-cdef-1234-567890abcdef") == \ + "1234567890abcdef1234567890abcdef" + + def test_notion_url_with_dashed_id(self): + url = "https://www.notion.so/Working-Diary-12345678-90ab-cdef-1234-567890abcdef" + assert parse_page_id(url) == "1234567890abcdef1234567890abcdef" + + def test_notion_url_with_undashed_id_at_end(self): + url = "https://notion.so/workspace/1234567890abcdef1234567890abcdef?v=foo" + assert parse_page_id(url) == "1234567890abcdef1234567890abcdef" + + def test_invalid_returns_none(self): + assert parse_page_id("not a uuid") is None + assert parse_page_id("") is None + assert parse_page_id(None) is None + + def test_too_short_returns_none(self): + assert parse_page_id("abc123") is None + + +class TestVerifyAccess: + def test_both_checks_succeed(self): + mock_req = MagicMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {} + mock_resp.headers = {} + mock_req.request.return_value = mock_resp + + with patch.dict("sys.modules", {"requests": mock_req}): + ok, err = _verify_access("token_xxx", "page_id_yyy") + assert ok is True + assert err == "" + + def test_invalid_token_returns_friendly_error(self): + mock_req = MagicMock() + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.json.return_value = {"message": "unauthorized"} + mock_resp.headers = {} + mock_req.request.return_value = mock_resp + + with patch.dict("sys.modules", {"requests": mock_req}): + ok, err = _verify_access("bad_token", "page_id") + assert ok is False + assert "Token invalid" in err + + def test_page_not_found_returns_share_hint(self): + mock_req = MagicMock() + # First call (users/me) succeeds, second (blocks/{id}) returns 404 + resp_ok = MagicMock(status_code=200, headers={}) + resp_ok.json.return_value = {} + resp_404 = MagicMock(status_code=404, headers={}) + resp_404.json.return_value = {"message": "not found"} + mock_req.request.side_effect = [resp_ok, resp_404] + + with patch.dict("sys.modules", {"requests": mock_req}): + ok, err = _verify_access("token", "missing_page") + assert ok is False + assert "Connections" in err # mentions the share path + + +class TestSaveCredentials: + def test_merges_into_existing_config(self, tmp_path): + with patch("claude_diary.cli.notion_init.load_config", + return_value={"lang": "ko", "exporters": {"slack": {"webhook_url": "x"}}}), \ + patch("claude_diary.cli.notion_init.save_config") as mock_save: + _save_credentials("token_abc", "page_xyz") + + saved = mock_save.call_args[0][0] + assert saved["lang"] == "ko" + assert saved["exporters"]["slack"]["webhook_url"] == "x" + assert saved["exporters"]["notion_hierarchical"]["api_token"] == "token_abc" + assert saved["exporters"]["notion_hierarchical"]["root_page_id"] == "page_xyz" + + def test_creates_keys_when_missing(self, tmp_path): + with patch("claude_diary.cli.notion_init.load_config", return_value={}), \ + patch("claude_diary.cli.notion_init.save_config") as mock_save: + _save_credentials("t", "p") + saved = mock_save.call_args[0][0] + assert saved["exporters"]["notion_hierarchical"]["api_token"] == "t" + + +# ── End-to-end cmd_notion_init ──────────────────────────────────────────── + +class TestCmdNotionInit: + def _make_args(self): + return MagicMock() + + def test_happy_path(self, tmp_path, capsys): + page_id = "1234567890abcdef1234567890abcdef" + + with patch("claude_diary.cli.notion_init.getpass.getpass", return_value="secret_xxx"), \ + patch("builtins.input", return_value=page_id), \ + patch("claude_diary.cli.notion_init._verify_access", return_value=(True, "")), \ + patch("claude_diary.cli.notion_init._save_credentials") as mock_save: + cmd_notion_init(self._make_args()) + + mock_save.assert_called_once_with("secret_xxx", page_id) + + def test_aborts_on_empty_token(self, capsys): + with patch("claude_diary.cli.notion_init.getpass.getpass", return_value=""), \ + pytest.raises(SystemExit) as exc: + cmd_notion_init(self._make_args()) + assert exc.value.code == 1 + + def test_aborts_on_empty_page(self): + with patch("claude_diary.cli.notion_init.getpass.getpass", return_value="secret_x"), \ + patch("builtins.input", return_value=""), \ + pytest.raises(SystemExit) as exc: + cmd_notion_init(self._make_args()) + assert exc.value.code == 1 + + def test_aborts_on_unparseable_page(self): + with patch("claude_diary.cli.notion_init.getpass.getpass", return_value="secret_x"), \ + patch("builtins.input", return_value="not a real notion url"), \ + pytest.raises(SystemExit) as exc: + cmd_notion_init(self._make_args()) + assert exc.value.code == 1 + + def test_aborts_on_verify_failure(self): + page_id = "1234567890abcdef1234567890abcdef" + with patch("claude_diary.cli.notion_init.getpass.getpass", return_value="bad"), \ + patch("builtins.input", return_value=page_id), \ + patch("claude_diary.cli.notion_init._verify_access", + return_value=(False, "✗ Token invalid")), \ + pytest.raises(SystemExit) as exc: + cmd_notion_init(self._make_args()) + assert exc.value.code == 1 + + def test_keyboard_interrupt_during_token_aborts(self): + with patch("claude_diary.cli.notion_init.getpass.getpass", + side_effect=KeyboardInterrupt), \ + pytest.raises(SystemExit) as exc: + cmd_notion_init(self._make_args()) + assert exc.value.code == 1 From 38c6139c2df8a15eb2bcf0d99c43bf8ba6158a56 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 26 May 2026 18:07:08 +0900 Subject: [PATCH 06/30] =?UTF-8?q?feat:=20/diary-notion=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20install=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20(Phase=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.py: SLASH_COMMANDS dict로 일반화 (filename → (content, marker)) - DIARY_NOTION_SLASH_COMMAND 추가 (Branch 경계 → Semantic-first 분리 룰 + title/body_intro 가이드 + JSON 스키마 + 임시 파일 흐름) - _install_all_slash_commands / _uninstall_all_slash_commands - cmd_install/uninstall이 두 슬래시 커맨드 모두 처리 - 업그레이드 path: 기존 diary.md 보존, diary-notion.md만 추가 - 사용자 수정한 슬래시 파일은 marker로 판별, uninstall 시 보존 - 새 테스트 15개 (전체 553 통과) Co-Authored-By: Claude Opus 4.7 --- src/claude_diary/cli/setup.py | 143 +++++++++++++++++++++---- tests/test_setup.py | 192 ++++++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 tests/test_setup.py diff --git a/src/claude_diary/cli/setup.py b/src/claude_diary/cli/setup.py index c25e7d1..9704e8b 100644 --- a/src/claude_diary/cli/setup.py +++ b/src/claude_diary/cli/setup.py @@ -22,11 +22,93 @@ !`claude-diary write` """ +DIARY_NOTION_SLASH_COMMAND = """\ +--- +description: 현재 세션을 작업 단위로 분리해 Notion 업무일지 DB에 push +allowed-tools: + - Bash + - Read + - Write +--- + +# /diary-notion + +현재 세션의 transcript와 git 정보를 분석하여 Notion 업무일지 DB에 push. + +## 단계 + +1. **컨텍스트 수집** + - 이 세션의 user 메시지, 어시스턴트 응답, 호출한 도구 검토 + - `git log` 로 이 세션 중 만든 commit 조회 + +2. **작업 단위 분리 (Branch 경계 → Semantic-first)** + - **branch 경계 최우선**: 세션 중 `git switch` 로 branch가 바뀌면 무조건 새 task + - 같은 branch 안에서는 **의미 단위로 분리** (사고 흐름 = task) + - 한 commit이 여러 의미 단위에 걸치면 양쪽 task에 같은 hash 매핑 + - 큰 commit("fix: 5건 개선" 같은) 한 번에 묶지 말고 의미별로 분리 + - 짧은 follow-up("ㅇㅇ", "맞아") 은 직전 task에 흡수 + - **commit이 0개인 의논 세션도 정상** — 의미 단위로 task N개 생성 + +3. **각 task별 추출** + - `title`: 30~50자 명사구. 시제/주어/prefix/마침표 없음 + - ✅ "Notion DB 컬럼 스키마 결정", "git_info.py 리팩토링" + - ❌ "오늘 DB 의논했다", "[설계] DB 컬럼" + - `body_intro`: 1~3문장, 200~500자, 평어체, 결과 중심 + - transcript에 없는 내용 추가 금지 (추측 X) + - markdown 강조(`**굵게**`, `` `코드` ` ` ) 사용 OK + - `categories`: 1~3개 (design/refactor/bugfix/test/docs/infra/discussion 등 자유 라벨) + - `project`: 현재 cwd의 폴더명 + - `user_prompts`, `files_modified`, `files_created`, `commands_run`, `errors` + - `commit_hashes`: 이 task에 해당하는 commit (0개도 OK) + +4. **JSON 저장 및 CLI 호출** + - cwd 에 `.diary-notion-<8자리random>.json` 파일을 Write 도구로 생성 + - `!claude-diary notion push --input .diary-notion-<8자리>.json` 실행 + - 종료 후 임시 파일 삭제 (CLI도 try/finally로 삭제하지만 보험) + +## JSON 형식 + +```json +{ + "session_id": "<현재 세션 ID>", + "tasks": [ + { + "title": "...", + "body_intro": "...", + "categories": ["..."], + "project": "", + "user_prompts": ["..."], + "files_modified": ["..."], + "files_created": ["..."], + "commands_run": ["..."], + "commit_hashes": ["..."], + "errors": ["..."] + } + ] +} +``` + +## 빈 결과 + +`tasks` 가 0개라면 사용자에게 이유 설명 후 CLI 호출 없이 종료. -def _get_slash_command_path(): - """Return path to ~/.claude/commands/diary.md.""" +## 보고 + +CLI 출력을 사용자에게 그대로 보여주고 어떤 task가 push/skip/fail 되었는지 간략 요약. +""" + + +SLASH_COMMANDS = { + # filename: (file content, marker substring used to detect "ours") + "diary.md": (DIARY_SLASH_COMMAND, "claude-diary write"), + "diary-notion.md": (DIARY_NOTION_SLASH_COMMAND, "claude-diary notion push"), +} + + +def _get_slash_command_path(filename="diary.md"): + """Return path to ~/.claude/commands/.""" home = os.path.expanduser("~") - return os.path.join(home, ".claude", "commands", "diary.md") + return os.path.join(home, ".claude", "commands", filename) def _get_claude_settings_path(): @@ -74,7 +156,7 @@ def _find_existing_hook(settings): def cmd_install(args): - """Register claude-diary Stop hook + /diary slash command.""" + """Register claude-diary Stop hook + all slash commands.""" settings_path = _get_claude_settings_path() settings = _load_claude_settings(settings_path) @@ -90,21 +172,30 @@ def cmd_install(args): hook_status = "installed" # Slash command install runs unconditionally — fixes upgrade path for - # users who installed before /diary was a feature. - slash_path = _get_slash_command_path() - slash_status = _install_slash_command(slash_path) + # users who installed before a given slash command was a feature. + slash_statuses = _install_all_slash_commands() print("claude-diary install:") print(" Hook: %s (%s)" % (HOOK_COMMAND, hook_status)) print(" Settings: %s" % settings_path) - print(" Slash command: %s (%s)" % (slash_path, slash_status)) + for filename, (path, status) in slash_statuses.items(): + print(" Slash command %s: %s (%s)" % (filename, path, status)) print() print("Stop Hook auto-writes a diary entry on session exit.") - print("Type /diary inside any session to write a manual entry on demand.") + print("Type /diary to write a manual entry, or /diary-notion to push to Notion.") -def _install_slash_command(path): - """Create ~/.claude/commands/diary.md if missing. +def _install_all_slash_commands(): + """Install every slash command in SLASH_COMMANDS. Returns {filename: (path, status)}.""" + results = {} + for filename, (content, _marker) in SLASH_COMMANDS.items(): + path = _get_slash_command_path(filename) + results[filename] = (path, _install_slash_command(path, content)) + return results + + +def _install_slash_command(path, content): + """Create the slash command file if missing. Returns 'installed', 'already exists', or 'failed: '. """ @@ -113,20 +204,20 @@ def _install_slash_command(path): try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: - f.write(DIARY_SLASH_COMMAND) + f.write(content) return "installed" except OSError as e: return "failed: %s" % e -def _uninstall_slash_command(path): - """Remove ~/.claude/commands/diary.md if it matches our content.""" +def _uninstall_slash_command(path, marker): + """Remove the slash command file only if its content contains the marker.""" if not os.path.exists(path): return "not present" try: with open(path, "r", encoding="utf-8") as f: existing = f.read() - if "claude-diary write" not in existing: + if marker not in existing: return "skipped (modified by user)" os.remove(path) return "removed" @@ -134,13 +225,27 @@ def _uninstall_slash_command(path): return "failed: %s" % e +def _uninstall_all_slash_commands(): + """Uninstall every slash command in SLASH_COMMANDS. Returns {filename: (path, status)}.""" + results = {} + for filename, (_content, marker) in SLASH_COMMANDS.items(): + path = _get_slash_command_path(filename) + results[filename] = (path, _uninstall_slash_command(path, marker)) + return results + + def cmd_uninstall(args): - """Remove claude-diary Stop hook from ~/.claude/settings.json.""" + """Remove claude-diary Stop hook + slash commands.""" settings_path = _get_claude_settings_path() settings = _load_claude_settings(settings_path) if not _find_existing_hook(settings): print("claude-diary hook is not installed.") + # Still clean up slash commands if any are present + slash_statuses = _uninstall_all_slash_commands() + for filename, (path, status) in slash_statuses.items(): + if status != "not present": + print(" Slash command %s: %s (%s)" % (filename, path, status)) return # Remove diary hooks @@ -161,9 +266,9 @@ def cmd_uninstall(args): _save_claude_settings(settings_path, settings) - slash_path = _get_slash_command_path() - slash_status = _uninstall_slash_command(slash_path) + slash_statuses = _uninstall_all_slash_commands() print("claude-diary hook uninstalled.") print(" Settings: %s" % settings_path) - print(" Slash command: %s (%s)" % (slash_path, slash_status)) + for filename, (path, status) in slash_statuses.items(): + print(" Slash command %s: %s (%s)" % (filename, path, status)) diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..bf73668 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,192 @@ +"""Tests for install/uninstall — Stop hook + slash commands.""" + +import json +import os +from unittest.mock import patch, MagicMock + +from claude_diary.cli.setup import ( + SLASH_COMMANDS, + DIARY_SLASH_COMMAND, + DIARY_NOTION_SLASH_COMMAND, + cmd_install, + cmd_uninstall, + _install_slash_command, + _uninstall_slash_command, + _install_all_slash_commands, + _uninstall_all_slash_commands, +) + + +def _patch_home(tmp_path): + """Force ~/.claude paths to land under tmp_path.""" + return patch.dict(os.environ, { + "HOME": str(tmp_path), # POSIX + "USERPROFILE": str(tmp_path), # Windows + }) + + +class TestSlashCommandRegistry: + def test_both_commands_registered(self): + assert "diary.md" in SLASH_COMMANDS + assert "diary-notion.md" in SLASH_COMMANDS + + def test_each_entry_has_content_and_marker(self): + for filename, entry in SLASH_COMMANDS.items(): + content, marker = entry + assert content + assert marker in content, "Marker %r must be findable in %s" % (marker, filename) + + +class TestInstallSlashCommandSingle: + def test_creates_file_when_missing(self, tmp_path): + path = tmp_path / "new-command.md" + result = _install_slash_command(str(path), "hello content") + assert result == "installed" + assert path.read_text(encoding="utf-8") == "hello content" + + def test_preserves_existing_file(self, tmp_path): + path = tmp_path / "existing.md" + path.write_text("user customized", encoding="utf-8") + result = _install_slash_command(str(path), "default content") + assert result == "already exists" + assert path.read_text(encoding="utf-8") == "user customized" + + def test_creates_parent_directory(self, tmp_path): + path = tmp_path / "subdir" / "deep" / "file.md" + result = _install_slash_command(str(path), "x") + assert result == "installed" + assert path.exists() + + +class TestUninstallSlashCommandSingle: + def test_removes_when_marker_present(self, tmp_path): + path = tmp_path / "x.md" + path.write_text("---\n...!claude-diary write\n", encoding="utf-8") + result = _uninstall_slash_command(str(path), "claude-diary write") + assert result == "removed" + assert not path.exists() + + def test_preserves_when_user_modified(self, tmp_path): + path = tmp_path / "x.md" + path.write_text("my custom content", encoding="utf-8") + result = _uninstall_slash_command(str(path), "claude-diary write") + assert result == "skipped (modified by user)" + assert path.exists() + + def test_returns_not_present_when_missing(self, tmp_path): + path = tmp_path / "nonexistent.md" + result = _uninstall_slash_command(str(path), "marker") + assert result == "not present" + + +class TestInstallAll: + def test_installs_both_commands(self, tmp_path): + with _patch_home(tmp_path): + results = _install_all_slash_commands() + assert "diary.md" in results + assert "diary-notion.md" in results + # Both files created + diary = tmp_path / ".claude" / "commands" / "diary.md" + diary_notion = tmp_path / ".claude" / "commands" / "diary-notion.md" + assert diary.exists() + assert diary_notion.exists() + assert "claude-diary write" in diary.read_text(encoding="utf-8") + assert "claude-diary notion push" in diary_notion.read_text(encoding="utf-8") + + def test_upgrade_path_keeps_existing_diary(self, tmp_path): + """If user already has /diary from old install, /diary-notion still gets added.""" + commands_dir = tmp_path / ".claude" / "commands" + commands_dir.mkdir(parents=True) + existing_diary = commands_dir / "diary.md" + existing_diary.write_text("user-customized content", encoding="utf-8") + + with _patch_home(tmp_path): + results = _install_all_slash_commands() + assert results["diary.md"][1] == "already exists" + assert results["diary-notion.md"][1] == "installed" + # User's diary.md is untouched + assert existing_diary.read_text(encoding="utf-8") == "user-customized content" + + +class TestUninstallAll: + def test_removes_only_unmodified_files(self, tmp_path): + commands_dir = tmp_path / ".claude" / "commands" + commands_dir.mkdir(parents=True) + ours = commands_dir / "diary.md" + ours.write_text(DIARY_SLASH_COMMAND, encoding="utf-8") + notion_modified = commands_dir / "diary-notion.md" + notion_modified.write_text("totally custom — no marker", encoding="utf-8") + + with _patch_home(tmp_path): + results = _uninstall_all_slash_commands() + + assert results["diary.md"][1] == "removed" + assert results["diary-notion.md"][1] == "skipped (modified by user)" + assert not ours.exists() + assert notion_modified.exists() + + +class TestCmdInstall: + def test_writes_hook_and_both_slashes_into_empty_settings(self, tmp_path, capsys): + settings_path = tmp_path / ".claude" / "settings.json" + with _patch_home(tmp_path): + cmd_install(MagicMock()) + + # Hook registered + assert settings_path.exists() + settings = json.loads(settings_path.read_text(encoding="utf-8")) + stop_hooks = settings["hooks"]["Stop"] + assert any( + "claude_diary.hook" in h["command"] + for group in stop_hooks for h in group["hooks"] + ) + # Both slash commands present + assert (tmp_path / ".claude" / "commands" / "diary.md").exists() + assert (tmp_path / ".claude" / "commands" / "diary-notion.md").exists() + + def test_idempotent_when_hook_already_present(self, tmp_path): + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text(json.dumps({ + "hooks": {"Stop": [{"hooks": [{"type": "command", + "command": "python -m claude_diary.hook"}]}]} + }), encoding="utf-8") + + with _patch_home(tmp_path): + cmd_install(MagicMock()) + + after = json.loads(settings_path.read_text(encoding="utf-8")) + # No duplicate group added + stop = after["hooks"]["Stop"] + diary_hooks = [ + h for group in stop for h in group["hooks"] + if "claude_diary.hook" in h["command"] + ] + assert len(diary_hooks) == 1 + + +class TestCmdUninstall: + def test_removes_hook_and_both_slashes(self, tmp_path): + # Set up an installed state + with _patch_home(tmp_path): + cmd_install(MagicMock()) + + # Now uninstall + with _patch_home(tmp_path): + cmd_uninstall(MagicMock()) + + settings_path = tmp_path / ".claude" / "settings.json" + settings = json.loads(settings_path.read_text(encoding="utf-8")) + assert "hooks" not in settings # cleaned up + assert not (tmp_path / ".claude" / "commands" / "diary.md").exists() + assert not (tmp_path / ".claude" / "commands" / "diary-notion.md").exists() + + def test_no_hook_still_cleans_unmodified_slashes(self, tmp_path): + commands_dir = tmp_path / ".claude" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "diary.md").write_text(DIARY_SLASH_COMMAND, encoding="utf-8") + + with _patch_home(tmp_path): + cmd_uninstall(MagicMock()) + # Still gets cleaned up + assert not (commands_dir / "diary.md").exists() From b48b6ea152b358adb31d9b03cd47cda5be3a19c3 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 26 May 2026 18:08:39 +0900 Subject: [PATCH 07/30] =?UTF-8?q?docs:=20README=20+=20CHANGELOG=EC=97=90?= =?UTF-8?q?=20/diary-notion=205=EB=B6=84=20=EC=85=8B=EC=97=85=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=B6=94=EA=B0=80=20(Phase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: 'Notion 업무일지 — /diary-notion 슬래시 커맨드' 섹션 추가 - 계층 구조 다이어그램 + 5단계 셋업 가이드 + 사용법 + DB 컬럼 표 - 설계 문서 링크 - 보안 주의 (config.json 공유 금지, .gitignore 권장 패턴) - CHANGELOG.md: [Unreleased] 섹션에 변경 요약 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 21 ++++++++++++++++++ README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6f638..2330111 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- **`/diary-notion` 슬래시 커맨드**: 현재 세션을 작업 단위로 분리해 Notion 업무일지 DB에 push + - 계층 구조: 루트 페이지 → 연도 페이지 → 단일 Entries DB (자동 생성) + - 작업 분리: branch 경계 → 의미 단위 (semantic-first) + - LLM은 슬래시 커맨드 안의 Claude가 처리 — 별도 Anthropic API 키 불필요 + - 멱등성: Session ID + Task Index 컬럼으로 skip, `--force` 시 archive&recreate + - 에러 분기: 401/403 fail fast, 400 skip, 429/5xx retry, 404 자동 재생성 +- **`claude-diary notion init`**: 대화형 셋업 (token + 페이지 URL/ID + 권한 검증) +- **`claude-diary notion push --input ` `--force`**: 임시 JSON 파일 받아 Notion에 push +- **DB 자동 생성 스키마**: Name, Date, Project, Branch, Categories, Files, Commits, Lines, Session ID, Task Index +- **`lib/notion_cache.py`**: 연도 페이지/DB/행 ID 캐시 (root_page_id 변경 시 자동 무효화) +- **`lib/git_info.py` 확장**: `get_branch_for_commit`, `get_head_branch`, `get_commit_info`, `get_diff_stat_for_commits` +- **새 테스트 90개** (전체 553 통과) + +### Changed +- `cli/setup.py` 일반화: `SLASH_COMMANDS` dict로 다중 슬래시 커맨드 관리 +- `formatter.py`: Notion API blocks 빌더 (`build_notion_blocks`) 추가 +- 설계 문서: [`docs/02-design/features/diary-notion-hierarchical.design.md`](docs/02-design/features/diary-notion-hierarchical.design.md) + ## [4.1.0] - 2026-03-17 (Phase D) ### Added diff --git a/README.md b/README.md index 2cc36e8..081ef39 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,68 @@ cd claude-code-hooks-diary/working-diary-system `claude-diary install` 시 `~/.claude/commands/diary.md`가 함께 설치되어 모든 프로젝트에서 `/diary` 사용 가능. 이미 설치한 적 있다면 한 번 더 실행해서 슬래시 커맨드만 추가하세요 (멱등). `claude-diary uninstall` 시 함께 제거됩니다 (사용자가 수정한 파일은 보존). +## Notion 업무일지 — `/diary-notion` 슬래시 커맨드 + +현재 세션을 **작업 단위로 분리**해 Notion DB에 push합니다. Claude Code 구독으로 동작하며 **별도 Anthropic API 키 필요 없음**, Notion 무료 플랜에서도 동작. + +``` +[Notion 루트 페이지: "Working Diary"] + └── 📄 2026 (자동 생성) + └── 🗄️ Entries (인라인 DB, 자동 생성) + ├── "Notion DB 컬럼 스키마 결정" | Project: claude-diary | Branch: feat/notion + ├── "git_info.py 리팩토링" | Project: claude-diary | Branch: feat/notion + └── ... +``` + +한 세션의 의논/구현이 의미 단위로 N개 행으로 분리되어 들어갑니다. branch가 바뀌면 무조건 새 task로 분리. + +### 5분 셋업 + +1. **Notion Integration 토큰 발급** — https://www.notion.so/my-integrations → "New integration" → 토큰 복사 (`secret_...`) +2. **Notion에 루트 페이지 생성** — 이름 자유 (예: "Working Diary") +3. **그 페이지를 Integration에 공유** — 페이지 우상단 ⋯ → "Connections" → 만든 Integration 추가 +4. **셋업 명령 실행**: + ```bash + claude-diary notion init + ``` + 대화형으로 token과 root page URL(또는 ID)을 입력하면 권한 검증 후 config에 저장됩니다. +5. **세션에서 `/diary-notion` 입력** — 작업 분리 + Notion push 자동 실행 + +### 사용법 + +```bash +# 처음 한 번 +claude-diary notion init + +# 매 세션 +/diary-notion # Claude Code 세션 안에서 + +# 같은 세션 다시 push (실수 등): +# 기본은 skip (Session ID + Task Index로 멱등성) +# --force 로 기존 행 archive 후 재push +``` + +### DB 컬럼 + +| 컬럼 | 타입 | 비고 | +|------|------|------| +| Name | title | Claude가 뽑은 task 제목 (명사구) | +| Date | date | | +| Project | select | cwd 폴더명. group/filter용 | +| Branch | select | task별 branch (group/filter용) | +| Categories | multi_select | design/refactor/bugfix/... 자유 라벨 | +| Files | number | 수정+생성 파일 수 | +| Commits | number | task별 commit 수 | +| Lines | number | 추가+삭제 합 | +| Session ID, Task Index | (hidden 권장) | 멱등성 키 | + +자세한 설계는 [`docs/02-design/features/diary-notion-hierarchical.design.md`](docs/02-design/features/diary-notion-hierarchical.design.md). + +### 주의 + +- `config.json`은 절대 git에 커밋/공유하지 마세요 (token이 평문 저장됨) +- 사용자 프로젝트 `.gitignore`에 `.diary-notion-*.json` 추가 권장 (임시 파일 보호망) + ## 일지 예시 ```markdown From 120e01973f81e476e604f7efb1913d321c11b0cc Mon Sep 17 00:00:00 2001 From: cys Date: Wed, 27 May 2026 10:31:35 +0900 Subject: [PATCH 08/30] =?UTF-8?q?docs:=20Status=20/=20Task=20Group=20/=20D?= =?UTF-8?q?epends=20On=20=EC=BB=AC=EB=9F=BC=20=EA=B2=B0=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(Phase=206a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB 스키마에 3개 컬럼 추가 (표시 11 + hidden 2 = 13개): - Status (select, 5단계): Discussion/Design/Implementation/Testing/Deployed - Task Group (select): 큰 작업 단위 묶음 - Depends On (relation, self-ref, 단방향): 선행 작업 참조 - JSON 스키마에 status/task_group/depends_on_indices 필드 추가 - 결정 #20~23 (마이그레이션 정책 포함) Co-Authored-By: Claude Opus 4.7 --- .../diary-notion-hierarchical.design.md | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/02-design/features/diary-notion-hierarchical.design.md b/docs/02-design/features/diary-notion-hierarchical.design.md index f5d4872..bf3f141 100644 --- a/docs/02-design/features/diary-notion-hierarchical.design.md +++ b/docs/02-design/features/diary-notion-hierarchical.design.md @@ -78,14 +78,30 @@ | Date | date | ✅ | 2026-05-26 | 정렬/필터/캘린더 뷰 | | Project | select | ✅ | claude-diary | group/filter | | Branch | select | ✅ | feat/diary-notion | group/filter. CLI가 자동 채움 | +| Status | select | ✅ | Implementation | 5단계: Discussion/Design/Implementation/Testing/Deployed | +| Task Group | select | ✅ | diary-notion-impl | 큰 작업 단위 묶음. Claude가 추출 | | Categories | multi_select | ✅ | design, notion | 작업 성격 | | Files | number | ✅ | 7 | 수정+생성 파일 수 | | Commits | number | ✅ | 3 | 커밋 개수 | | Lines | number | ✅ | 142 | 추가+삭제 합 | +| Depends On | relation (self) | ✅ | → 다른 행 | 선행 작업 참조 (단방향) | | Session ID | rich_text | 🔒 hidden | "abc-123-def" | 멱등성 키 | | Task Index | number | 🔒 hidden | 0, 1, 2 | 멱등성 키 | -→ 표시 8개 + hidden 2개 = 총 10개. 의미 요약은 컬럼이 아닌 본문(`body_intro`)으로만 노출. +→ 표시 11개 + hidden 2개 = 총 13개. 의미 요약은 컬럼이 아닌 본문(`body_intro`)으로만 노출. + +**Status 5단계 (select)**: +- `Discussion`: 의논만, 결정 미완 (드물게) +- `Design`: 결정/문서화 완료 +- `Implementation`: 코드 작성 (commit 있음) +- `Testing`: 테스트 작성/검증 완료 +- `Deployed`: 머지/배포까지 완료 + +한 task에 여러 단계가 섞이면 **가장 진행된 단계로**. Claude가 transcript 보고 판단. + +**Task Group (select)**: 며칠/여러 세션에 걸치는 큰 작업 단위 묶음. Claude가 첫 task 시점에 새 그룹명 생성, 이전 작업의 연속이면 같은 그룹명 사용. 일관성 보장은 어렵지만 group view로 묶어 보는 편의가 핵심 가치. + +**Depends On (self-relation, 단방향)**: 같은 DB 안의 다른 행 참조. JSON 스키마의 `depends_on_indices` 가 같은 push의 task index를 가리킴. CLI가 push 순서대로 row_id 누적 → 인덱스를 실제 row ID로 변환해서 relation 채움. Notion이 자동 reverse view 제공해 단방향 정의로 양방향 효과. **Branch 컬럼 데이터 소스** (CLI 자동): - task의 `commit_hashes` 있으면 → 첫 commit의 branch (`git branch --contains`) @@ -136,6 +152,9 @@ { "title": "Notion DB 컬럼 스키마 결정", "body_intro": "DB 컬럼을 Layer 1/2로 분리. 단일 통합 DB + Project select 채택. summary 컬럼은 본문 첫 문단(body_intro)으로 통합.", + "status": "Design", + "task_group": "diary-notion-impl", + "depends_on_indices": [], "categories": ["design", "notion"], "project": "claude-code-hooks-diary", "user_prompts": ["DB 구조 의논하자", "name에는 날짜 말고..."], @@ -344,6 +363,10 @@ Saved to: /config.json | 12 | Body intro 톤 | 평어체, 1~3문장, 결과 중심, markdown 강조 OK, 추측 금지 | 글로벌 지침과 일관. 회고 시 빠른 회상 | | 13 | summary 컬럼 | 삭제 — `body_intro` 만 유지 | 사용자가 본문 위주로 보기 때문. 중복 제거 | | 14 | JSON 전달 방식 | 임시 파일 (cwd, `.diary-notion-.json`) | PowerShell 호환. escape 문제 회피. 디버깅 쉬움 | +| 20 | Status 컬럼 | select, 5단계 (Discussion/Design/Implementation/Testing/Deployed) | 진행도 시각화. 한 task 안에 여러 단계 섞이면 가장 진행된 단계 | +| 21 | Depends On 컬럼 | self-relation, 단방향 | 작업 순서 시각화. Notion이 reverse view 자동 제공 | +| 22 | Task Group 컬럼 | select. Claude가 task별로 추출 | 며칠/여러 세션에 걸치는 큰 작업을 group view로 묶기 | +| 23 | 멱등성 + 새 컬럼 마이그레이션 | 기존 행 archive(`--force`) 후 새 스키마로 재push | Status/Depends On/Task Group 소급 채움 | | 15 | Branch 컬럼 추가 + 경계 룰 | Branch select 컬럼 + "branch 다르면 task 분리"를 최우선 분리 룰로 | 한 task = 한 branch 보장. select 컬럼이 의미 있어짐. 여러 branch 섞이는 케이스 자동 해결 | | 16 | Error 종류별 분기 | 401/403 fail fast, 400 skip, 429/5xx retry, 404 자동 재생성 | 의미 없는 retry 방지. 캐시 일관성 자동 복구 | | 17 | Retry 정책 | 인라인 retry 3회 (exponential backoff) + JSON 파일 보존 | 수동 명령에 동기적 보고. queue 안 씀 | From 01737aeb9b4e97166291cbac14317b14f51a20df Mon Sep 17 00:00:00 2001 From: cys Date: Wed, 27 May 2026 10:33:24 +0900 Subject: [PATCH 09/30] =?UTF-8?q?feat:=20Notion=20DB=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=EC=97=90=20Status/Task=20Group=20+=20self-relation=20?= =?UTF-8?q?Depends=20On=20=EC=B6=94=EA=B0=80=20(Phase=206b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exporters/notion_hierarchical.py: _create_database() - Status (select), Task Group (select) 컬럼 추가 - DB 생성 직후 _add_depends_on_relation()으로 PATCH /databases/{id} 호출해 self-referential 'Depends On' relation 컬럼 추가 (database_id가 생성 후에야 알려지므로 2단계 호출 필수) - test_notion_hierarchical.py: 새 컬럼 + PATCH 호출 검증 - 전체 553 통과 Co-Authored-By: Claude Opus 4.7 --- .../exporters/notion_hierarchical.py | 35 ++++++++++++++++--- tests/test_notion_hierarchical.py | 32 ++++++++++++++--- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/claude_diary/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py index eb629af..c6455cc 100644 --- a/src/claude_diary/exporters/notion_hierarchical.py +++ b/src/claude_diary/exporters/notion_hierarchical.py @@ -224,10 +224,14 @@ def ensure_database(self, year): def _create_database(self, parent_page_id): """Create the Entries inline database with the agreed schema. - Schema (decision #3, #15): - Name (title), Date, Project (select), Branch (select), + Schema (decisions #3, #15, #20, #21, #22): + Name (title), Date, Project, Branch, Status, Task Group (select), Categories (multi_select), Files, Commits, Lines (number), - Session ID (rich_text — hidden), Task Index (number — hidden) + Session ID (rich_text — hidden), Task Index (number — hidden). + + Depends On (self-relation) is added in a second PATCH call because + the relation needs the database_id, which isn't known until the + DB exists. """ body = { "parent": {"type": "page_id", "page_id": parent_page_id}, @@ -238,6 +242,8 @@ def _create_database(self, parent_page_id): "Date": {"date": {}}, "Project": {"select": {}}, "Branch": {"select": {}}, + "Status": {"select": {}}, + "Task Group": {"select": {}}, "Categories": {"multi_select": {}}, "Files": {"number": {}}, "Commits": {"number": {}}, @@ -247,7 +253,28 @@ def _create_database(self, parent_page_id): }, } resp = self._request("POST", "/databases", body) - return resp["id"] + db_id = resp["id"] + self._add_depends_on_relation(db_id) + return db_id + + def _add_depends_on_relation(self, db_id): + """PATCH the database to add a self-referential 'Depends On' relation. + + Self-relation can't be declared at create time because the + database_id doesn't exist yet. Notion accepts the self-ref via + a follow-up PATCH /v1/databases/{id}. + """ + self._request("PATCH", "/databases/%s" % db_id, { + "properties": { + "Depends On": { + "relation": { + "database_id": db_id, + "type": "single_property", + "single_property": {}, + } + } + } + }) def find_existing_row(self, db_id, session_id, task_index): """Return row page ID if a row with the same Session ID + Task Index exists.""" diff --git a/tests/test_notion_hierarchical.py b/tests/test_notion_hierarchical.py index 4878f3b..53b774c 100644 --- a/tests/test_notion_hierarchical.py +++ b/tests/test_notion_hierarchical.py @@ -212,19 +212,41 @@ def test_creates_database_with_full_schema(self, tmp_path): exp._cache["years"]["2026"] = "year_page" # year already cached mock_req = MagicMock() - mock_req.request.return_value = _make_response(200, {"id": "db_xyz"}) + # Three calls: + # 1. GET /blocks/year_page (existence check from ensure_year_page cache hit) + # 2. POST /databases (create) + # 3. PATCH /databases/{id} (self-relation) + mock_req.request.side_effect = [ + _make_response(200, {"id": "year_page"}), + _make_response(200, {"id": "db_xyz"}), + _make_response(200, {"id": "db_xyz"}), + ] with _patch_requests(mock_req): db_id = exp.ensure_database(2026) assert db_id == "db_xyz" - # Inspect the database-create POST body - create_body = mock_req.request.call_args.kwargs["json"] + # Second call: create POST + create_call = mock_req.request.call_args_list[1] + assert create_call.args[0] == "POST" + create_body = create_call.kwargs["json"] assert create_body["parent"]["page_id"] == "year_page" assert create_body["is_inline"] is True props = create_body["properties"] - for col in ["Name", "Date", "Project", "Branch", "Categories", - "Files", "Commits", "Lines", "Session ID", "Task Index"]: + for col in ["Name", "Date", "Project", "Branch", "Status", "Task Group", + "Categories", "Files", "Commits", "Lines", + "Session ID", "Task Index"]: assert col in props + # Depends On NOT in create body (added by PATCH) + assert "Depends On" not in props + + # Third call: PATCH to add self-relation + patch_call = mock_req.request.call_args_list[2] + assert patch_call.args[0] == "PATCH" + assert patch_call.args[1].endswith("/databases/db_xyz") + patch_body = patch_call.kwargs["json"] + assert "Depends On" in patch_body["properties"] + relation = patch_body["properties"]["Depends On"]["relation"] + assert relation["database_id"] == "db_xyz" # self-reference class TestFindExistingRow: From e13971a8aaa0fc3d1922d51852f6dab698270c80 Mon Sep 17 00:00:00 2001 From: cys Date: Wed, 27 May 2026 10:37:20 +0900 Subject: [PATCH 10/30] =?UTF-8?q?feat:=202-pass=20push=20(status/task=5Fgr?= =?UTF-8?q?oup/depends=5Fon)=20+=20=EC=8A=AC=EB=9E=98=EC=8B=9C=20instructi?= =?UTF-8?q?ons=20=EA=B0=B1=EC=8B=A0=20(Phase=206c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exporters/notion_hierarchical.py: update_row_relation() 추가 (페이지 PATCH로 Depends On relation 설정) - cli/notion_push.py: 2-pass 흐름 - Pass 1: 모든 행 생성 (Depends On 비워둠) → task_index → row_id 맵 누적 - Pass 2: depends_on_indices 가 있는 task만 PATCH로 relation 채움 - _build_properties: status (VALID_STATUSES 검증), task_group 추가 - cli/setup.py: - DIARY_NOTION_SLASH_COMMAND 갱신 (status/task_group/depends_on_indices 가이드) - _install_slash_command(--force): 사용자 수정 안 한 파일만 덮어쓰기 - cli/__init__.py: install --force 옵션 - 새 테스트 12개, 전체 565 통과 Co-Authored-By: Claude Opus 4.7 --- src/claude_diary/cli/__init__.py | 4 +- src/claude_diary/cli/notion_push.py | 63 ++++++++++- src/claude_diary/cli/setup.py | 52 +++++++-- .../exporters/notion_hierarchical.py | 14 +++ tests/test_notion_push.py | 104 ++++++++++++++++++ tests/test_setup.py | 38 +++++++ 6 files changed, 259 insertions(+), 16 deletions(-) diff --git a/src/claude_diary/cli/__init__.py b/src/claude_diary/cli/__init__.py index 44a60f8..1ca4374 100644 --- a/src/claude_diary/cli/__init__.py +++ b/src/claude_diary/cli/__init__.py @@ -117,7 +117,9 @@ def main(): p_dashboard.add_argument("--months", type=int, default=3, help="Months of data (default: 3)") # install / uninstall - sub.add_parser("install", help="Register claude-diary hook in Claude Code") + p_install = sub.add_parser("install", help="Register claude-diary hook in Claude Code") + p_install.add_argument("--force", action="store_true", + help="Overwrite slash command files (preserves user-modified ones)") sub.add_parser("uninstall", help="Remove claude-diary hook from Claude Code") # write (manual diary — for /diary slash command) diff --git a/src/claude_diary/cli/notion_push.py b/src/claude_diary/cli/notion_push.py index 7572b14..1e5252a 100644 --- a/src/claude_diary/cli/notion_push.py +++ b/src/claude_diary/cli/notion_push.py @@ -83,17 +83,21 @@ def cmd_notion_push(args): sys.exit(1) results = {"pushed": [], "skipped": [], "failed": []} + row_ids = {} # task_index → Notion row_id (used by 2nd pass for relations) auth_failed = False + # Pass 1: create rows (or detect existing). Depends On left blank for now. for idx, task in enumerate(tasks): title = task.get("title") or "(untitled)" if auth_failed: results["failed"].append((idx, title, "skipped after earlier auth error")) continue try: - outcome = _push_task( + outcome, row_id = _push_task( exporter, year, date_str, session_id, idx, task, cwd, lang ) + if row_id: + row_ids[idx] = row_id if outcome == "pushed": results["pushed"].append((idx, title)) else: @@ -106,6 +110,11 @@ def cmd_notion_push(args): except Exception as e: results["failed"].append((idx, title, str(e))) + # Pass 2: wire up Depends On relations now that all row_ids are known. + relation_failures = _wire_depends_on(exporter, tasks, row_ids) + for idx, title, reason in relation_failures: + results["failed"].append((idx, title, "depends_on: %s" % reason)) + exporter.save_cache() _print_report(results, input_path) @@ -116,6 +125,30 @@ def cmd_notion_push(args): sys.exit(1 if auth_failed else 0) +def _wire_depends_on(exporter, tasks, row_ids): + """Pass 2: set Depends On relations using the row_ids gathered in pass 1. + + Returns a list of (idx, title, reason) for tasks whose relation update + failed. Tasks without depends_on are silently skipped. + """ + failures = [] + for idx, task in enumerate(tasks): + deps = task.get("depends_on_indices") or [] + if not deps: + continue + my_row = row_ids.get(idx) + if not my_row: + continue # task itself failed in pass 1 + target_rows = [row_ids[d] for d in deps if d in row_ids] + if not target_rows: + continue + try: + exporter.update_row_relation(my_row, target_rows) + except Exception as e: + failures.append((idx, task.get("title") or "(untitled)", str(e))) + return failures + + def _resolve_credentials(config): """Resolve token and root_page_id from env vars first, then config.""" notion_cfg = (config.get("exporters") or {}).get("notion_hierarchical") or {} @@ -148,12 +181,16 @@ def _fallback_session_id(): def _push_task(exporter, year, date_str, session_id, task_index, task, cwd, lang): - """Push one task to Notion. Returns 'pushed' or 'skipped'.""" + """Push one task to Notion. Returns (outcome, row_id). + + outcome ∈ {"pushed", "skipped"}. row_id is the Notion page ID for + this task — either the newly created one or the existing match. + """ db_id = exporter.ensure_database(year) existing = exporter.find_existing_row(db_id, session_id, task_index) if existing: - return "skipped" + return "skipped", existing git_info = _gather_git_info(cwd, task.get("commit_hashes") or []) branch = git_info.get("branch") or "" @@ -165,7 +202,7 @@ def _push_task(exporter, year, date_str, session_id, task_index, task, cwd, lang row_id = exporter.create_row(db_id, properties, body_blocks) notion_cache.set_row(exporter._cache, session_id, task_index, row_id) - return "pushed" + return "pushed", row_id def _gather_git_info(cwd, commit_hashes): @@ -183,8 +220,15 @@ def _gather_git_info(cwd, commit_hashes): return info +VALID_STATUSES = {"Discussion", "Design", "Implementation", "Testing", "Deployed"} + + def _build_properties(task, date_str, branch, git_info, session_id, task_index): - """Build Notion DB row properties from task data.""" + """Build Notion DB row properties from task data. + + Note: `Depends On` relation is NOT set here — it's wired up in pass 2 + via update_row_relation() once all row IDs are known. + """ title = task.get("title") or "(untitled)" project = (task.get("project") or "unknown").strip() or "unknown" categories = [c for c in (task.get("categories") or []) if c] @@ -211,6 +255,15 @@ def _build_properties(task, date_str, branch, git_info, session_id, task_index): } if branch: props["Branch"] = {"select": {"name": _safe_select(branch)}} + + status = (task.get("status") or "").strip() + if status in VALID_STATUSES: + props["Status"] = {"select": {"name": status}} + + task_group = (task.get("task_group") or "").strip() + if task_group: + props["Task Group"] = {"select": {"name": _safe_select(task_group)}} + return props diff --git a/src/claude_diary/cli/setup.py b/src/claude_diary/cli/setup.py index 9704e8b..c68f10f 100644 --- a/src/claude_diary/cli/setup.py +++ b/src/claude_diary/cli/setup.py @@ -56,6 +56,16 @@ - `body_intro`: 1~3문장, 200~500자, 평어체, 결과 중심 - transcript에 없는 내용 추가 금지 (추측 X) - markdown 강조(`**굵게**`, `` `코드` ` ` ) 사용 OK + - `status`: 5단계 중 하나 — `Discussion` / `Design` / `Implementation` / `Testing` / `Deployed` + - 한 task에 여러 단계 섞이면 **가장 진행된 단계로** (Testing 통과까지 했으면 Testing) + - 결정만 했으면 Design, 코드 작성까지 했으면 Implementation, 테스트까지 했으면 Testing, + 머지/배포까지 했으면 Deployed + - `task_group`: 며칠/여러 세션에 걸치는 큰 작업 단위 식별자 (예: `diary-notion-impl`, `auth-refactor`) + - 같은 큰 작업의 task들끼리 같은 그룹명 사용 → Notion에서 group view로 묶임 + - 이전 작업의 연속이면 같은 그룹명, 새 작업이면 새 그룹명 (snake-case/kebab-case 권장) + - `depends_on_indices`: 이 task가 선행을 의존하는 다른 task의 **같은 push 내 인덱스 배열** (예: `[0, 1]`) + - 같은 JSON 안의 tasks 배열 순서 (0-base) 기준 + - 없으면 빈 배열 `[]` - `categories`: 1~3개 (design/refactor/bugfix/test/docs/infra/discussion 등 자유 라벨) - `project`: 현재 cwd의 폴더명 - `user_prompts`, `files_modified`, `files_created`, `commands_run`, `errors` @@ -75,6 +85,9 @@ { "title": "...", "body_intro": "...", + "status": "Implementation", + "task_group": "diary-notion-impl", + "depends_on_indices": [0, 1], "categories": ["..."], "project": "", "user_prompts": ["..."], @@ -156,7 +169,11 @@ def _find_existing_hook(settings): def cmd_install(args): - """Register claude-diary Stop hook + all slash commands.""" + """Register claude-diary Stop hook + all slash commands. + + With --force, overwrite existing slash command files (useful after a + claude-diary upgrade that changed slash command instructions). + """ settings_path = _get_claude_settings_path() settings = _load_claude_settings(settings_path) @@ -171,9 +188,10 @@ def cmd_install(args): _save_claude_settings(settings_path, settings) hook_status = "installed" + force = bool(getattr(args, "force", False)) # Slash command install runs unconditionally — fixes upgrade path for # users who installed before a given slash command was a feature. - slash_statuses = _install_all_slash_commands() + slash_statuses = _install_all_slash_commands(force=force) print("claude-diary install:") print(" Hook: %s (%s)" % (HOOK_COMMAND, hook_status)) @@ -185,27 +203,41 @@ def cmd_install(args): print("Type /diary to write a manual entry, or /diary-notion to push to Notion.") -def _install_all_slash_commands(): +def _install_all_slash_commands(force=False): """Install every slash command in SLASH_COMMANDS. Returns {filename: (path, status)}.""" results = {} - for filename, (content, _marker) in SLASH_COMMANDS.items(): + for filename, (content, marker) in SLASH_COMMANDS.items(): path = _get_slash_command_path(filename) - results[filename] = (path, _install_slash_command(path, content)) + results[filename] = (path, _install_slash_command(path, content, marker, force)) return results -def _install_slash_command(path, content): - """Create the slash command file if missing. +def _install_slash_command(path, content, marker=None, force=False): + """Create or refresh the slash command file. - Returns 'installed', 'already exists', or 'failed: '. + Without `force`: skip if the file already exists (preserves user customizations). + With `force`: overwrite only if the existing file looks like ours (contains + `marker`); files modified by the user are still preserved. + + Returns 'installed', 'overwritten', 'already exists', 'skipped (modified by user)', + or 'failed: '. """ - if os.path.exists(path): + exists = os.path.exists(path) + if exists and not force: return "already exists" + if exists and force: + try: + with open(path, "r", encoding="utf-8") as f: + existing = f.read() + except OSError as e: + return "failed: %s" % e + if marker and marker not in existing: + return "skipped (modified by user)" try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(content) - return "installed" + return "overwritten" if exists else "installed" except OSError as e: return "failed: %s" % e diff --git a/src/claude_diary/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py index c6455cc..437c752 100644 --- a/src/claude_diary/exporters/notion_hierarchical.py +++ b/src/claude_diary/exporters/notion_hierarchical.py @@ -343,6 +343,20 @@ def create_row(self, db_id, properties, body_blocks): resp = self._request("POST", "/pages", body) return resp["id"] + def update_row_relation(self, row_id, depends_on_row_ids): + """PATCH a row to set its `Depends On` relation. + + Used by the 2-pass push flow: rows are created first (so we have + their IDs), then dependency edges are wired up in a second pass. + """ + self._request("PATCH", "/pages/%s" % row_id, { + "properties": { + "Depends On": { + "relation": [{"id": rid} for rid in depends_on_row_ids] + } + } + }) + def _short_error(resp): """Extract a one-line error description from a Notion error response.""" diff --git a/tests/test_notion_push.py b/tests/test_notion_push.py index 1d1e2b9..ce5ace0 100644 --- a/tests/test_notion_push.py +++ b/tests/test_notion_push.py @@ -120,6 +120,110 @@ def test_no_commits_zeros(self): assert props["Commits"]["number"] == 0 assert props["Lines"]["number"] == 0 + def test_valid_status_included(self): + task = dict(self._base_task(), status="Implementation") + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert props["Status"]["select"]["name"] == "Implementation" + + def test_invalid_status_omitted(self): + task = dict(self._base_task(), status="MadeUpValue") + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert "Status" not in props + + def test_task_group_included(self): + task = dict(self._base_task(), task_group="diary-notion-impl") + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert props["Task Group"]["select"]["name"] == "diary-notion-impl" + + def test_empty_task_group_omitted(self): + task = dict(self._base_task(), task_group="") + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert "Task Group" not in props + + def test_depends_on_not_in_properties(self): + """Depends On relation is wired up in pass 2, not in _build_properties.""" + task = dict(self._base_task(), depends_on_indices=[0, 1]) + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert "Depends On" not in props + + +class TestDependsOnWiring: + def _make_args(self, input_path, force=False): + args = MagicMock() + args.input = input_path + args.force = force + return args + + def _write_json(self, path, data): + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + + def test_wire_depends_on_calls_update_for_each_task_with_deps(self, tmp_path): + input_path = tmp_path / "in.json" + self._write_json(str(input_path), { + "session_id": "s1", + "tasks": [ + {"title": "A", "depends_on_indices": []}, + {"title": "B", "depends_on_indices": [0]}, # depends on A + {"title": "C", "depends_on_indices": [0, 1]}, # depends on A and B + ], + }) + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + + mock_exp = MagicMock() + mock_exp.ensure_database.return_value = "db_xyz" + mock_exp.find_existing_row.return_value = None + # Three create_row calls → three row IDs + mock_exp.create_row.side_effect = ["row_a", "row_b", "row_c"] + mock_exp._cache = {"rows": {}, "years": {}, "databases": {}, "root_page_id": "p"} + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter", + return_value=mock_exp), \ + patch("claude_diary.cli.notion_push.get_head_branch", return_value="main"), \ + pytest.raises(SystemExit): + cmd_notion_push(self._make_args(str(input_path))) + + # update_row_relation called for B (deps=[A]) and C (deps=[A, B]), + # not for A (no deps). + rel_calls = mock_exp.update_row_relation.call_args_list + assert len(rel_calls) == 2 + # B → [A] + assert rel_calls[0].args == ("row_b", ["row_a"]) + # C → [A, B] + assert rel_calls[1].args == ("row_c", ["row_a", "row_b"]) + + def test_wire_depends_on_skips_missing_targets(self, tmp_path): + """If a referenced task index has no row_id (failed), the missing target is dropped.""" + from claude_diary.cli.notion_push import _wire_depends_on + mock_exp = MagicMock() + tasks = [ + {"title": "A"}, + {"title": "B", "depends_on_indices": [0, 99]}, # 99 doesn't exist + ] + row_ids = {0: "row_a", 1: "row_b"} + _wire_depends_on(mock_exp, tasks, row_ids) + # Only the valid target (0 → row_a) is passed + mock_exp.update_row_relation.assert_called_once_with("row_b", ["row_a"]) + + def test_wire_depends_on_skips_when_self_failed(self): + """If this task itself failed in pass 1, no relation update attempted.""" + from claude_diary.cli.notion_push import _wire_depends_on + mock_exp = MagicMock() + tasks = [ + {"title": "A"}, + {"title": "B", "depends_on_indices": [0]}, + ] + # Task 1 (B) has no row_id (failed in pass 1) + row_ids = {0: "row_a"} + _wire_depends_on(mock_exp, tasks, row_ids) + mock_exp.update_row_relation.assert_not_called() + class TestGatherGitInfo: def test_with_commit_hashes(self): diff --git a/tests/test_setup.py b/tests/test_setup.py index bf73668..629458a 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -79,6 +79,44 @@ def test_returns_not_present_when_missing(self, tmp_path): assert result == "not present" +class TestInstallForce: + def test_force_overwrites_when_marker_present(self, tmp_path): + path = tmp_path / "x.md" + path.write_text("---\nold content with claude-diary write\n", encoding="utf-8") + result = _install_slash_command(str(path), "new content with claude-diary write", + marker="claude-diary write", force=True) + assert result == "overwritten" + assert "new content" in path.read_text(encoding="utf-8") + + def test_force_preserves_user_modified(self, tmp_path): + path = tmp_path / "x.md" + path.write_text("totally custom — no marker", encoding="utf-8") + result = _install_slash_command(str(path), "new content", + marker="claude-diary write", force=True) + assert result == "skipped (modified by user)" + # Original content preserved + assert path.read_text(encoding="utf-8") == "totally custom — no marker" + + def test_force_creates_when_missing(self, tmp_path): + path = tmp_path / "new.md" + result = _install_slash_command(str(path), "fresh", marker="marker", force=True) + assert result == "installed" + + def test_cmd_install_with_force_refreshes_diary_notion(self, tmp_path): + commands_dir = tmp_path / ".claude" / "commands" + commands_dir.mkdir(parents=True) + (commands_dir / "diary-notion.md").write_text( + "---\nold instructions claude-diary notion push\n", encoding="utf-8" + ) + args = MagicMock() + args.force = True + with _patch_home(tmp_path): + cmd_install(args) + # File overwritten with the new bundled DIARY_NOTION_SLASH_COMMAND + content = (commands_dir / "diary-notion.md").read_text(encoding="utf-8") + assert "depends_on_indices" in content + + class TestInstallAll: def test_installs_both_commands(self, tmp_path): with _patch_home(tmp_path): From e70cb9698616b39c35d50deb0862d7869552f324 Mon Sep 17 00:00:00 2001 From: cys Date: Wed, 27 May 2026 10:45:18 +0900 Subject: [PATCH 11/30] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20DB=EC=97=90?= =?UTF-8?q?=EB=8F=84=20Status/Task=20Group/Depends=20On=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EA=B0=80=20(Phase=206c=20hotfix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 9개 행이 들어있던 이전 push의 DB에는 Status/Task Group 컬럼이 없어서 재push가 'property does not exist' 400 으로 실패. ensure_database가 cache hit 경로에서도 _ensure_db_schema_extensions를 호출하도록 변경 (idempotent — cache 에 schema_v 플래그 두고 한 번만 PATCH). - exporters/notion_hierarchical.py - _create_database에서 Status/Task Group 제거 (책임 분리) - _ensure_db_schema_extensions(): Status + Task Group + self-relation Depends On 한 번의 PATCH로 보장 - ensure_database가 cache hit/miss 둘 다 schema extension 호출 - lib/notion_cache.py: schema_v 키 load/save 지원 - 테스트 2건 추가 (기존 DB 업그레이드 path, 캐시 플래그로 skip) - 전체 567 통과 Co-Authored-By: Claude Opus 4.7 --- .diary-notion-migrate1.json | 301 ++++++++++++++++++ .../exporters/notion_hierarchical.py | 83 ++--- src/claude_diary/lib/notion_cache.py | 2 + tests/test_notion_hierarchical.py | 61 +++- 4 files changed, 402 insertions(+), 45 deletions(-) create mode 100644 .diary-notion-migrate1.json diff --git a/.diary-notion-migrate1.json b/.diary-notion-migrate1.json new file mode 100644 index 0000000..31b397f --- /dev/null +++ b/.diary-notion-migrate1.json @@ -0,0 +1,301 @@ +{ + "session_id": "diary-notion-impl-2026-05-27", + "tasks": [ + { + "title": "/diary-notion 요구사항 정의 및 기획", + "body_intro": "기존 `/diary` 슬래시 커맨드 동작을 확인하고, 그 위에 Notion 업무일지로 push하는 `/diary-notion` 슬래시 커맨드를 추가하기로 결정. 단순 flat DB가 아닌 **연도별 페이지 + 프로젝트별 분류 + 날짜별 행** 형태의 계층 구조로 가기로 방향 설정.", + "status": "Design", + "task_group": "diary-notion-impl", + "depends_on_indices": [], + "categories": ["discussion", "design"], + "project": "claude-code-hooks-diary", + "user_prompts": [ + "현재 이 프로젝트를 기반으로 내 pc에서 /diary 를 입력하면 해당 프로젝트에서 작업한 내용을 날짜별로 정리하도록 만들었지 그치?", + "그럼 /diary-notion 이란 명령어를 추가하고싶은데", + "노션에 md 파일을 만드는게 아니라 연도별 페이지를 만들고 > 해당 페이지 내 프로젝트별 인라인 데이터 베이스 생성 하고 > 그 데이터베이스에 날짜별 작업 내용을 정리하고싶은데 ( 업무일지 자동화 )" + ], + "files_modified": [], + "files_created": [], + "commands_run": [], + "commit_hashes": [], + "errors": [] + }, + { + "title": "/diary-notion 핵심 설계 결정 15건 의논", + "body_intro": "Notion 업무일지 자동화의 핵심 설계 결정들을 트레이드오프 비교 후 확정. **구조**: 루트 페이지 → 연도 페이지 → 단일 Entries DB (Project select 분류). **작업 분리**: 슬래시 커맨드 안의 Claude가 transcript를 semantic 단위로 분리 → 별도 Anthropic API 키 불필요. **본문**: `body_intro` 1~3문장 + CLI가 raw 섹션 조립. **멱등성**: Session ID + Task Index hidden 컬럼으로 skip, `--force` 시 archive&recreate. **에러**: 401/403 fail fast, 400 skip, 429/5xx exponential backoff, 404 자동 재생성.", + "status": "Design", + "task_group": "diary-notion-impl", + "depends_on_indices": [0], + "categories": ["design", "decision", "discussion"], + "project": "claude-code-hooks-diary", + "user_prompts": [ + "흠 a가 나을 것 같은데, 그리고 프로젝트명을 속성으로 선택해서 분류로 골라보면 될 것 같은데?", + "옵션 3번으로 가는게 제일 좋아보여", + "summary와 body_intro 의 차이가 정확하게 어떤 건지 이야기해보자", + "페이지 본문을 더 많이 봄", + "B + --force 로 가는 게 나을 것 같은데", + "한 번 더 검토했을 때 위 추천사항대로 진행하는 게 맞을까?" + ], + "files_modified": [], + "files_created": [], + "commands_run": [], + "commit_hashes": [], + "errors": [] + }, + { + "title": "/diary-notion 설계 문서 작성 + 브랜치 분리", + "body_intro": "확정된 설계 결정 14건을 `docs/02-design/features/diary-notion-hierarchical.design.md` 에 정리 (447 lines). `feat/diary-notion` 브랜치를 새로 만들어 main과 격리하고 첫 commit. 향후 구현 commit들이 한 PR로 묶일 수 있도록 작업 격리.", + "status": "Design", + "task_group": "diary-notion-impl", + "depends_on_indices": [1], + "categories": ["docs", "design"], + "project": "claude-code-hooks-diary", + "user_prompts": [ + "확정 여기까지 내용을 별도 커밋하고 싶은데 흠 브랜치를 따로 만드는 게 좋을려나", + "feat/diary-notion 이렇게 별도 브랜치로 만들고싶은데" + ], + "files_modified": [], + "files_created": [ + "docs/02-design/features/diary-notion-hierarchical.design.md" + ], + "commands_run": [ + "git checkout -b feat/diary-notion", + "git commit -m \"docs: /diary-notion 설계 ...\"" + ], + "commit_hashes": ["084dc8c"], + "errors": [] + }, + { + "title": "Branch 컬럼 + 'branch 경계 = task 경계' 분리 룰 보완", + "body_intro": "DB 스키마에 **Branch (select)** 컬럼을 추가하고, 작업 분리 룰을 **'branch 경계 → 의미 단위'** 로 보완. 같은 세션에서 `git switch` 하면 무조건 task 분리되어 Branch 컬럼이 group/filter용으로 의미 있어짐. 한 task = 한 branch 보장으로 \"여러 branch 섞임\" 케이스 자동 해결.", + "status": "Design", + "task_group": "diary-notion-impl", + "depends_on_indices": [2], + "categories": ["design", "schema"], + "project": "claude-code-hooks-diary", + "user_prompts": [ + "그런데 구현하기 전에, 데이터베이스에서 프로젝트명이 있고, 이제 git 기준으로 작성하는 부분도 있짢아, 그때 해당 작업이 어느 브랜치에서 진행되고 잇는지도 표기하고싶은데", + "흠 작업한 브랜치가 다르다면, 페이지를 개별로 생성해야할듯?", + "a 맞아" + ], + "files_modified": [ + "docs/02-design/features/diary-notion-hierarchical.design.md" + ], + "files_created": [], + "commands_run": [ + "git commit -m \"docs: Branch 컬럼 추가 + ...\"" + ], + "commit_hashes": ["b64c300"], + "errors": [] + }, + { + "title": "Notion hierarchical exporter + ID 캐시 구현 (Phase 1)", + "body_intro": "Notion API 통신 + 페이지/DB/행 CRUD를 담당하는 **`exporters/notion_hierarchical.py`** 구현. 401/403→`NotionAuthError`, 404→`NotionNotFound`, 400→`NotionBadRequest` 로 타입 분리, 429는 Retry-After 준수, 5xx는 exponential backoff 3회. **`lib/notion_cache.py`** 는 연도/DB/행 ID를 캐싱하며 root_page_id 변경 시 자동 무효화. 단위 테스트 34개로 전체 477 통과.", + "status": "Testing", + "task_group": "diary-notion-impl", + "depends_on_indices": [3], + "categories": ["feat", "infrastructure", "test"], + "project": "claude-code-hooks-diary", + "user_prompts": [ + "구현하자", + "A로 진행하자" + ], + "files_modified": [], + "files_created": [ + "src/claude_diary/lib/notion_cache.py", + "src/claude_diary/exporters/notion_hierarchical.py", + "tests/test_notion_cache.py", + "tests/test_notion_hierarchical.py" + ], + "commands_run": [ + "python -m pytest", + "git commit -m \"feat: Notion hierarchical exporter ... (Phase 1)\"" + ], + "commit_hashes": ["f4d1138"], + "errors": [] + }, + { + "title": "notion-push CLI + Notion blocks 빌더 (Phase 2)", + "body_intro": "**`cli/notion_push.py`** 구현 — JSON 받아 git_info 수집 → entry_data 빌드 → 멱등성 체크 → push까지 파이프라인. 부분 실패 시 임시 JSON 파일 보존. **`formatter.py`** 에 `build_notion_blocks()` 추가, **`git_info.py`** 에 commit별 branch/info 함수 4개 추가. **`cli/__init__.py`** 에 `notion ` 그룹 명령 등록. 새 테스트 44개로 전체 521 통과.", + "status": "Testing", + "task_group": "diary-notion-impl", + "depends_on_indices": [4], + "categories": ["feat", "cli", "test"], + "project": "claude-code-hooks-diary", + "user_prompts": [], + "files_modified": [ + "src/claude_diary/cli/__init__.py", + "src/claude_diary/formatter.py", + "src/claude_diary/lib/git_info.py", + "tests/test_formatter.py", + "tests/test_git_info.py" + ], + "files_created": [ + "src/claude_diary/cli/notion_push.py", + "tests/test_notion_push.py" + ], + "commands_run": [ + "python -m pytest", + "git commit -m \"feat: notion-push CLI ... (Phase 2)\"" + ], + "commit_hashes": ["9c07222"], + "errors": [] + }, + { + "title": "notion init 대화형 셋업 명령 (Phase 3)", + "body_intro": "**`cli/notion_init.py`** 구현 — `getpass` 로 token 가려서 입력, `parse_page_id` 가 dashed UUID / undashed 32hex / Notion URL 모두 지원하여 canonical 32hex 추출. `GET /v1/users/me` (token 유효성) + `GET /v1/blocks/{id}` (페이지 접근) 사전 검증. 실패 시 친절한 안내. 성공 시 `exporters.notion_hierarchical` 키로 config 저장. 새 테스트 17개로 전체 538 통과.", + "status": "Testing", + "task_group": "diary-notion-impl", + "depends_on_indices": [5], + "categories": ["feat", "cli", "test"], + "project": "claude-code-hooks-diary", + "user_prompts": [], + "files_modified": [ + "src/claude_diary/cli/__init__.py" + ], + "files_created": [ + "src/claude_diary/cli/notion_init.py", + "tests/test_notion_init.py" + ], + "commands_run": [ + "python -m pytest", + "git commit -m \"feat: notion init ... (Phase 3)\"" + ], + "commit_hashes": ["90e4f22"], + "errors": [] + }, + { + "title": "/diary-notion 슬래시 커맨드 install 통합 (Phase 4)", + "body_intro": "**`cli/setup.py`** 의 single-slash-command 패턴을 **`SLASH_COMMANDS` dict** 로 일반화하여 두 슬래시 커맨드 동시 관리. `DIARY_NOTION_SLASH_COMMAND` 추가 (Branch 경계 룰 + title/body_intro 가이드 + JSON 스키마). 업그레이드 path 보존 — 기존 `diary.md` 그대로, `diary-notion.md` 만 신규 설치. 사용자 수정 파일은 marker로 판별. 새 테스트 15개로 전체 553 통과.", + "status": "Testing", + "task_group": "diary-notion-impl", + "depends_on_indices": [6], + "categories": ["feat", "refactor", "test"], + "project": "claude-code-hooks-diary", + "user_prompts": [], + "files_modified": [ + "src/claude_diary/cli/setup.py" + ], + "files_created": [ + "tests/test_setup.py" + ], + "commands_run": [ + "python -m pytest", + "git commit -m \"feat: /diary-notion 슬래시 커맨드 install 통합 (Phase 4)\"" + ], + "commit_hashes": ["38c6139"], + "errors": [] + }, + { + "title": "README/CHANGELOG 가이드 + remote push + 셋업 검증 (Phase 5)", + "body_intro": "**README.md** 에 \"Notion 업무일지 — /diary-notion 슬래시 커맨드\" 섹션 추가 (계층 구조 다이어그램, 5분 셋업 가이드, DB 컬럼 표, 보안 주의). **CHANGELOG.md** `[Unreleased]` 에 변경 요약. `git push -u origin feat/diary-notion` 으로 remote에 7 commits push. 사용자가 `claude-diary notion init` 직접 실행하여 token + 실제 Notion 페이지 권한 검증 성공.", + "status": "Testing", + "task_group": "diary-notion-impl", + "depends_on_indices": [7], + "categories": ["docs", "infra", "verify"], + "project": "claude-code-hooks-diary", + "user_prompts": [ + "일단 여기 작업까지 커밋 후 푸쉬하자", + "현재 이렇게 터미널에서 실행되면 되는건가?" + ], + "files_modified": [ + "README.md", + "CHANGELOG.md" + ], + "files_created": [], + "commands_run": [ + "git push -u origin feat/diary-notion", + "claude-diary notion init" + ], + "commit_hashes": ["b48b6ea"], + "errors": [] + }, + { + "title": "Status / Task Group / Depends On 컬럼 설계 갱신 (Phase 6a)", + "body_intro": "노션 행에 **진행도(Status, select 5단계)** + **종속성(Depends On, self-relation 단방향)** + **큰 작업 묶음(Task Group)** 컬럼을 추가하기로 결정. 같은 작업이 며칠에 걸쳐 진행되는 시나리오를 옵션 A(매번 새 task + depends_on) + 옵션 B(Task Group으로 묶음) 조합으로 표현하기로 합의. JSON 스키마와 design.md에 결정 #20~23 추가.", + "status": "Design", + "task_group": "diary-notion-impl", + "depends_on_indices": [8], + "categories": ["design", "schema", "decision"], + "project": "claude-code-hooks-diary", + "user_prompts": [ + "흠 좋은데 혹시, 노션에 기록할 때 진행도도 같이 표현할 수 있나? 그리고 작업들의 전후를 연결할 수 있어? 노션 데이터베이스의 종속성 항목을 통해서 말이야", + "1. 더 자세하게 처리 / 단방향 / c", + "a+b 로 진행하자" + ], + "files_modified": [ + "docs/02-design/features/diary-notion-hierarchical.design.md" + ], + "files_created": [], + "commands_run": [ + "git commit -m \"docs: Status / Task Group / Depends On 컬럼 결정 추가 (Phase 6a)\"" + ], + "commit_hashes": ["120e019"], + "errors": [] + }, + { + "title": "DB 스키마 + self-relation Depends On 추가 (Phase 6b)", + "body_intro": "**`exporters/notion_hierarchical.py::_create_database()`** 에 Status/Task Group 컬럼 추가. DB 생성 직후 **`_add_depends_on_relation()`** 으로 PATCH `/v1/databases/{id}` 호출해 self-referential Depends On relation 컬럼 추가 (database_id가 생성 후에야 알려지므로 2단계 호출 필수). 기존 테스트 update 후 전체 553 통과.", + "status": "Testing", + "task_group": "diary-notion-impl", + "depends_on_indices": [9], + "categories": ["feat", "infrastructure", "test"], + "project": "claude-code-hooks-diary", + "user_prompts": [], + "files_modified": [ + "src/claude_diary/exporters/notion_hierarchical.py", + "tests/test_notion_hierarchical.py" + ], + "files_created": [], + "commands_run": [ + "python -m pytest", + "git commit -m \"feat: Notion DB 스키마에 Status/Task Group + self-relation Depends On 추가 (Phase 6b)\"" + ], + "commit_hashes": ["01737ae"], + "errors": [] + }, + { + "title": "notion-push 2-pass + 슬래시 instructions 갱신 (Phase 6c)", + "body_intro": "`cli/notion_push.py` 를 **2-pass 흐름** 으로 리팩토링 — Pass 1에서 모든 행 생성 후 task_index → row_id 맵 누적, Pass 2에서 `depends_on_indices` 가 있는 task만 PATCH로 relation 채움. `_build_properties` 에 status/task_group 추가 (VALID_STATUSES 검증). 슬래시 instructions에 status 5단계 가이드 + task_group/depends_on_indices 추출 가이드 추가. `install --force` 옵션으로 슬래시 커맨드 파일 강제 갱신. 새 테스트 12개로 전체 565 통과.", + "status": "Testing", + "task_group": "diary-notion-impl", + "depends_on_indices": [10], + "categories": ["feat", "cli", "test"], + "project": "claude-code-hooks-diary", + "user_prompts": [], + "files_modified": [ + "src/claude_diary/cli/notion_push.py", + "src/claude_diary/cli/setup.py", + "src/claude_diary/cli/__init__.py", + "src/claude_diary/exporters/notion_hierarchical.py", + "tests/test_notion_push.py", + "tests/test_setup.py" + ], + "files_created": [], + "commands_run": [ + "python -m pytest", + "git commit -m \"feat: 2-pass push ... (Phase 6c)\"" + ], + "commit_hashes": ["e13971a"], + "errors": [] + }, + { + "title": "Status/Depends On 마이그레이션 + Notion 검증 (Phase 6d)", + "body_intro": "`claude-diary install --force` 로 슬래시 커맨드 파일 갱신 후, 기존 9개 행 archive 하고 `status` / `task_group` / `depends_on_indices` 포함한 13개 task로 재push. Phase 6 작업 자체 (a/b/c/d) 도 같은 push에 포함. Notion DB에서 Status 5단계 표시 + Task Group 으로 group view + Depends On 순차 관계 사슬 확인.", + "status": "Testing", + "task_group": "diary-notion-impl", + "depends_on_indices": [11], + "categories": ["infra", "verify", "migration"], + "project": "claude-code-hooks-diary", + "user_prompts": [], + "files_modified": [], + "files_created": [], + "commands_run": [ + "claude-diary install --force", + "claude-diary notion push --input .diary-notion-migrate1.json --force" + ], + "commit_hashes": [], + "errors": [] + } + ] +} diff --git a/src/claude_diary/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py index 437c752..84789bf 100644 --- a/src/claude_diary/exporters/notion_hierarchical.py +++ b/src/claude_diary/exporters/notion_hierarchical.py @@ -206,32 +206,62 @@ def _create_year_page(self, year): return resp["id"] def ensure_database(self, year): - """Get Entries database ID for a year, creating if missing.""" + """Get Entries database ID for a year, creating if missing. + + Also ensures the schema extensions (Status, Task Group, Depends On) + are present — needed for older DBs created before those columns + were part of the design. Tracked via cache so we only patch once. + """ cached = notion_cache.get_database(self._cache, year) + db_id = None if cached: try: self._request("GET", "/databases/%s" % cached) - return cached + db_id = cached except NotionNotFound: logger.warning("Database for %s not found, recreating", year) notion_cache.set_database(self._cache, year, None) - year_page_id = self.ensure_year_page(year) - db_id = self._create_database(year_page_id) - notion_cache.set_database(self._cache, year, db_id) + if db_id is None: + year_page_id = self.ensure_year_page(year) + db_id = self._create_database(year_page_id) + notion_cache.set_database(self._cache, year, db_id) + + self._ensure_db_schema_extensions(db_id) return db_id - def _create_database(self, parent_page_id): - """Create the Entries inline database with the agreed schema. + def _ensure_db_schema_extensions(self, db_id): + """Add Status/Task Group/Depends On if not yet recorded in cache. + + Patching the same property twice is harmless (Notion treats existing + properties as no-ops), but we still gate by a cache flag to skip the + API call on the happy path. + """ + schema_v = self._cache.setdefault("schema_v", {}) + if schema_v.get(db_id) == "v2": + return + self._request("PATCH", "/databases/%s" % db_id, { + "properties": { + "Status": {"select": {}}, + "Task Group": {"select": {}}, + "Depends On": { + "relation": { + "database_id": db_id, + "type": "single_property", + "single_property": {}, + } + }, + } + }) + schema_v[db_id] = "v2" - Schema (decisions #3, #15, #20, #21, #22): - Name (title), Date, Project, Branch, Status, Task Group (select), - Categories (multi_select), Files, Commits, Lines (number), - Session ID (rich_text — hidden), Task Index (number — hidden). + def _create_database(self, parent_page_id): + """Create the Entries inline database with the base schema. - Depends On (self-relation) is added in a second PATCH call because - the relation needs the database_id, which isn't known until the - DB exists. + Status, Task Group, and the self-relation Depends On are added + afterwards by `_ensure_db_schema_extensions` — keeping them out + of this call means new and pre-existing DBs go through the same + upgrade path. """ body = { "parent": {"type": "page_id", "page_id": parent_page_id}, @@ -242,8 +272,6 @@ def _create_database(self, parent_page_id): "Date": {"date": {}}, "Project": {"select": {}}, "Branch": {"select": {}}, - "Status": {"select": {}}, - "Task Group": {"select": {}}, "Categories": {"multi_select": {}}, "Files": {"number": {}}, "Commits": {"number": {}}, @@ -253,28 +281,7 @@ def _create_database(self, parent_page_id): }, } resp = self._request("POST", "/databases", body) - db_id = resp["id"] - self._add_depends_on_relation(db_id) - return db_id - - def _add_depends_on_relation(self, db_id): - """PATCH the database to add a self-referential 'Depends On' relation. - - Self-relation can't be declared at create time because the - database_id doesn't exist yet. Notion accepts the self-ref via - a follow-up PATCH /v1/databases/{id}. - """ - self._request("PATCH", "/databases/%s" % db_id, { - "properties": { - "Depends On": { - "relation": { - "database_id": db_id, - "type": "single_property", - "single_property": {}, - } - } - } - }) + return resp["id"] def find_existing_row(self, db_id, session_id, task_index): """Return row page ID if a row with the same Session ID + Task Index exists.""" diff --git a/src/claude_diary/lib/notion_cache.py b/src/claude_diary/lib/notion_cache.py index 8ff33ab..ea1acbf 100644 --- a/src/claude_diary/lib/notion_cache.py +++ b/src/claude_diary/lib/notion_cache.py @@ -50,6 +50,7 @@ def load(root_page_id): "years": data.get("years") or {}, "databases": data.get("databases") or {}, "rows": data.get("rows") or {}, + "schema_v": data.get("schema_v") or {}, } @@ -67,6 +68,7 @@ def _empty(root_page_id): "years": {}, "databases": {}, "rows": {}, + "schema_v": {}, } diff --git a/tests/test_notion_hierarchical.py b/tests/test_notion_hierarchical.py index 53b774c..70fa82d 100644 --- a/tests/test_notion_hierarchical.py +++ b/tests/test_notion_hierarchical.py @@ -214,8 +214,8 @@ def test_creates_database_with_full_schema(self, tmp_path): mock_req = MagicMock() # Three calls: # 1. GET /blocks/year_page (existence check from ensure_year_page cache hit) - # 2. POST /databases (create) - # 3. PATCH /databases/{id} (self-relation) + # 2. POST /databases (create base schema) + # 3. PATCH /databases/{id} (Status + Task Group + Depends On extension) mock_req.request.side_effect = [ _make_response(200, {"id": "year_page"}), _make_response(200, {"id": "db_xyz"}), @@ -225,28 +225,75 @@ def test_creates_database_with_full_schema(self, tmp_path): db_id = exp.ensure_database(2026) assert db_id == "db_xyz" - # Second call: create POST + # Second call: create POST (base schema only) create_call = mock_req.request.call_args_list[1] assert create_call.args[0] == "POST" create_body = create_call.kwargs["json"] assert create_body["parent"]["page_id"] == "year_page" assert create_body["is_inline"] is True props = create_body["properties"] - for col in ["Name", "Date", "Project", "Branch", "Status", "Task Group", + for col in ["Name", "Date", "Project", "Branch", "Categories", "Files", "Commits", "Lines", "Session ID", "Task Index"]: assert col in props - # Depends On NOT in create body (added by PATCH) + # Status/Task Group/Depends On NOT in create body — added by schema extension PATCH + assert "Status" not in props + assert "Task Group" not in props assert "Depends On" not in props - # Third call: PATCH to add self-relation + # Third call: PATCH to add Status + Task Group + Depends On patch_call = mock_req.request.call_args_list[2] assert patch_call.args[0] == "PATCH" assert patch_call.args[1].endswith("/databases/db_xyz") patch_body = patch_call.kwargs["json"] - assert "Depends On" in patch_body["properties"] + for col in ["Status", "Task Group", "Depends On"]: + assert col in patch_body["properties"] relation = patch_body["properties"]["Depends On"]["relation"] assert relation["database_id"] == "db_xyz" # self-reference + # Cache flag recorded so future calls skip the PATCH + assert exp._cache["schema_v"]["db_xyz"] == "v2" + + def test_existing_database_gets_schema_extension(self, tmp_path): + """Cache-hit database (older schema) gets Status/Task Group/Depends On via PATCH.""" + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + # Pre-existing DB in cache (no schema_v flag — simulates upgrade path) + exp._cache["databases"]["2026"] = "old_db" + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, {"id": "old_db"}), # GET /databases/old_db (exists) + _make_response(200, {"id": "old_db"}), # PATCH schema extension + ] + with _patch_requests(mock_req): + db_id = exp.ensure_database(2026) + + assert db_id == "old_db" + # Second call: PATCH for schema extension on the existing DB + patch_call = mock_req.request.call_args_list[1] + assert patch_call.args[0] == "PATCH" + for col in ["Status", "Task Group", "Depends On"]: + assert col in patch_call.kwargs["json"]["properties"] + assert exp._cache["schema_v"]["old_db"] == "v2" + + def test_schema_extension_skipped_when_already_flagged(self, tmp_path): + """Once schema_v records the DB at v2, no further PATCH is sent.""" + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["databases"]["2026"] = "db_known" + exp._cache["schema_v"]["db_known"] = "v2" + + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "db_known"}) + with _patch_requests(mock_req): + db_id = exp.ensure_database(2026) + + assert db_id == "db_known" + # Only the existence check — no PATCH + assert mock_req.request.call_count == 1 + assert mock_req.request.call_args.args[0] == "GET" class TestFindExistingRow: From b1cba8ff32128684fbe13ba25268df3bcaa7d4bd Mon Sep 17 00:00:00 2001 From: cys Date: Wed, 27 May 2026 10:47:30 +0900 Subject: [PATCH 12/30] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20.diary-not?= =?UTF-8?q?ion-*.json=20=EC=B6=94=EA=B0=80=20+=20=EC=9E=98=EB=AA=BB=20?= =?UTF-8?q?=EB=93=A4=EC=96=B4=EA=B0=84=20=EC=9E=84=EC=8B=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 commit (e70cb96)에서 git add -A로 부분 실패 보존된 임시 JSON이 함께 staged됨. .diary-notion-*.json 패턴을 .gitignore에 추가해 향후 재발 방지. Co-Authored-By: Claude Opus 4.7 --- .diary-notion-migrate1.json | 301 ------------------------------------ .gitignore | 3 + 2 files changed, 3 insertions(+), 301 deletions(-) delete mode 100644 .diary-notion-migrate1.json diff --git a/.diary-notion-migrate1.json b/.diary-notion-migrate1.json deleted file mode 100644 index 31b397f..0000000 --- a/.diary-notion-migrate1.json +++ /dev/null @@ -1,301 +0,0 @@ -{ - "session_id": "diary-notion-impl-2026-05-27", - "tasks": [ - { - "title": "/diary-notion 요구사항 정의 및 기획", - "body_intro": "기존 `/diary` 슬래시 커맨드 동작을 확인하고, 그 위에 Notion 업무일지로 push하는 `/diary-notion` 슬래시 커맨드를 추가하기로 결정. 단순 flat DB가 아닌 **연도별 페이지 + 프로젝트별 분류 + 날짜별 행** 형태의 계층 구조로 가기로 방향 설정.", - "status": "Design", - "task_group": "diary-notion-impl", - "depends_on_indices": [], - "categories": ["discussion", "design"], - "project": "claude-code-hooks-diary", - "user_prompts": [ - "현재 이 프로젝트를 기반으로 내 pc에서 /diary 를 입력하면 해당 프로젝트에서 작업한 내용을 날짜별로 정리하도록 만들었지 그치?", - "그럼 /diary-notion 이란 명령어를 추가하고싶은데", - "노션에 md 파일을 만드는게 아니라 연도별 페이지를 만들고 > 해당 페이지 내 프로젝트별 인라인 데이터 베이스 생성 하고 > 그 데이터베이스에 날짜별 작업 내용을 정리하고싶은데 ( 업무일지 자동화 )" - ], - "files_modified": [], - "files_created": [], - "commands_run": [], - "commit_hashes": [], - "errors": [] - }, - { - "title": "/diary-notion 핵심 설계 결정 15건 의논", - "body_intro": "Notion 업무일지 자동화의 핵심 설계 결정들을 트레이드오프 비교 후 확정. **구조**: 루트 페이지 → 연도 페이지 → 단일 Entries DB (Project select 분류). **작업 분리**: 슬래시 커맨드 안의 Claude가 transcript를 semantic 단위로 분리 → 별도 Anthropic API 키 불필요. **본문**: `body_intro` 1~3문장 + CLI가 raw 섹션 조립. **멱등성**: Session ID + Task Index hidden 컬럼으로 skip, `--force` 시 archive&recreate. **에러**: 401/403 fail fast, 400 skip, 429/5xx exponential backoff, 404 자동 재생성.", - "status": "Design", - "task_group": "diary-notion-impl", - "depends_on_indices": [0], - "categories": ["design", "decision", "discussion"], - "project": "claude-code-hooks-diary", - "user_prompts": [ - "흠 a가 나을 것 같은데, 그리고 프로젝트명을 속성으로 선택해서 분류로 골라보면 될 것 같은데?", - "옵션 3번으로 가는게 제일 좋아보여", - "summary와 body_intro 의 차이가 정확하게 어떤 건지 이야기해보자", - "페이지 본문을 더 많이 봄", - "B + --force 로 가는 게 나을 것 같은데", - "한 번 더 검토했을 때 위 추천사항대로 진행하는 게 맞을까?" - ], - "files_modified": [], - "files_created": [], - "commands_run": [], - "commit_hashes": [], - "errors": [] - }, - { - "title": "/diary-notion 설계 문서 작성 + 브랜치 분리", - "body_intro": "확정된 설계 결정 14건을 `docs/02-design/features/diary-notion-hierarchical.design.md` 에 정리 (447 lines). `feat/diary-notion` 브랜치를 새로 만들어 main과 격리하고 첫 commit. 향후 구현 commit들이 한 PR로 묶일 수 있도록 작업 격리.", - "status": "Design", - "task_group": "diary-notion-impl", - "depends_on_indices": [1], - "categories": ["docs", "design"], - "project": "claude-code-hooks-diary", - "user_prompts": [ - "확정 여기까지 내용을 별도 커밋하고 싶은데 흠 브랜치를 따로 만드는 게 좋을려나", - "feat/diary-notion 이렇게 별도 브랜치로 만들고싶은데" - ], - "files_modified": [], - "files_created": [ - "docs/02-design/features/diary-notion-hierarchical.design.md" - ], - "commands_run": [ - "git checkout -b feat/diary-notion", - "git commit -m \"docs: /diary-notion 설계 ...\"" - ], - "commit_hashes": ["084dc8c"], - "errors": [] - }, - { - "title": "Branch 컬럼 + 'branch 경계 = task 경계' 분리 룰 보완", - "body_intro": "DB 스키마에 **Branch (select)** 컬럼을 추가하고, 작업 분리 룰을 **'branch 경계 → 의미 단위'** 로 보완. 같은 세션에서 `git switch` 하면 무조건 task 분리되어 Branch 컬럼이 group/filter용으로 의미 있어짐. 한 task = 한 branch 보장으로 \"여러 branch 섞임\" 케이스 자동 해결.", - "status": "Design", - "task_group": "diary-notion-impl", - "depends_on_indices": [2], - "categories": ["design", "schema"], - "project": "claude-code-hooks-diary", - "user_prompts": [ - "그런데 구현하기 전에, 데이터베이스에서 프로젝트명이 있고, 이제 git 기준으로 작성하는 부분도 있짢아, 그때 해당 작업이 어느 브랜치에서 진행되고 잇는지도 표기하고싶은데", - "흠 작업한 브랜치가 다르다면, 페이지를 개별로 생성해야할듯?", - "a 맞아" - ], - "files_modified": [ - "docs/02-design/features/diary-notion-hierarchical.design.md" - ], - "files_created": [], - "commands_run": [ - "git commit -m \"docs: Branch 컬럼 추가 + ...\"" - ], - "commit_hashes": ["b64c300"], - "errors": [] - }, - { - "title": "Notion hierarchical exporter + ID 캐시 구현 (Phase 1)", - "body_intro": "Notion API 통신 + 페이지/DB/행 CRUD를 담당하는 **`exporters/notion_hierarchical.py`** 구현. 401/403→`NotionAuthError`, 404→`NotionNotFound`, 400→`NotionBadRequest` 로 타입 분리, 429는 Retry-After 준수, 5xx는 exponential backoff 3회. **`lib/notion_cache.py`** 는 연도/DB/행 ID를 캐싱하며 root_page_id 변경 시 자동 무효화. 단위 테스트 34개로 전체 477 통과.", - "status": "Testing", - "task_group": "diary-notion-impl", - "depends_on_indices": [3], - "categories": ["feat", "infrastructure", "test"], - "project": "claude-code-hooks-diary", - "user_prompts": [ - "구현하자", - "A로 진행하자" - ], - "files_modified": [], - "files_created": [ - "src/claude_diary/lib/notion_cache.py", - "src/claude_diary/exporters/notion_hierarchical.py", - "tests/test_notion_cache.py", - "tests/test_notion_hierarchical.py" - ], - "commands_run": [ - "python -m pytest", - "git commit -m \"feat: Notion hierarchical exporter ... (Phase 1)\"" - ], - "commit_hashes": ["f4d1138"], - "errors": [] - }, - { - "title": "notion-push CLI + Notion blocks 빌더 (Phase 2)", - "body_intro": "**`cli/notion_push.py`** 구현 — JSON 받아 git_info 수집 → entry_data 빌드 → 멱등성 체크 → push까지 파이프라인. 부분 실패 시 임시 JSON 파일 보존. **`formatter.py`** 에 `build_notion_blocks()` 추가, **`git_info.py`** 에 commit별 branch/info 함수 4개 추가. **`cli/__init__.py`** 에 `notion ` 그룹 명령 등록. 새 테스트 44개로 전체 521 통과.", - "status": "Testing", - "task_group": "diary-notion-impl", - "depends_on_indices": [4], - "categories": ["feat", "cli", "test"], - "project": "claude-code-hooks-diary", - "user_prompts": [], - "files_modified": [ - "src/claude_diary/cli/__init__.py", - "src/claude_diary/formatter.py", - "src/claude_diary/lib/git_info.py", - "tests/test_formatter.py", - "tests/test_git_info.py" - ], - "files_created": [ - "src/claude_diary/cli/notion_push.py", - "tests/test_notion_push.py" - ], - "commands_run": [ - "python -m pytest", - "git commit -m \"feat: notion-push CLI ... (Phase 2)\"" - ], - "commit_hashes": ["9c07222"], - "errors": [] - }, - { - "title": "notion init 대화형 셋업 명령 (Phase 3)", - "body_intro": "**`cli/notion_init.py`** 구현 — `getpass` 로 token 가려서 입력, `parse_page_id` 가 dashed UUID / undashed 32hex / Notion URL 모두 지원하여 canonical 32hex 추출. `GET /v1/users/me` (token 유효성) + `GET /v1/blocks/{id}` (페이지 접근) 사전 검증. 실패 시 친절한 안내. 성공 시 `exporters.notion_hierarchical` 키로 config 저장. 새 테스트 17개로 전체 538 통과.", - "status": "Testing", - "task_group": "diary-notion-impl", - "depends_on_indices": [5], - "categories": ["feat", "cli", "test"], - "project": "claude-code-hooks-diary", - "user_prompts": [], - "files_modified": [ - "src/claude_diary/cli/__init__.py" - ], - "files_created": [ - "src/claude_diary/cli/notion_init.py", - "tests/test_notion_init.py" - ], - "commands_run": [ - "python -m pytest", - "git commit -m \"feat: notion init ... (Phase 3)\"" - ], - "commit_hashes": ["90e4f22"], - "errors": [] - }, - { - "title": "/diary-notion 슬래시 커맨드 install 통합 (Phase 4)", - "body_intro": "**`cli/setup.py`** 의 single-slash-command 패턴을 **`SLASH_COMMANDS` dict** 로 일반화하여 두 슬래시 커맨드 동시 관리. `DIARY_NOTION_SLASH_COMMAND` 추가 (Branch 경계 룰 + title/body_intro 가이드 + JSON 스키마). 업그레이드 path 보존 — 기존 `diary.md` 그대로, `diary-notion.md` 만 신규 설치. 사용자 수정 파일은 marker로 판별. 새 테스트 15개로 전체 553 통과.", - "status": "Testing", - "task_group": "diary-notion-impl", - "depends_on_indices": [6], - "categories": ["feat", "refactor", "test"], - "project": "claude-code-hooks-diary", - "user_prompts": [], - "files_modified": [ - "src/claude_diary/cli/setup.py" - ], - "files_created": [ - "tests/test_setup.py" - ], - "commands_run": [ - "python -m pytest", - "git commit -m \"feat: /diary-notion 슬래시 커맨드 install 통합 (Phase 4)\"" - ], - "commit_hashes": ["38c6139"], - "errors": [] - }, - { - "title": "README/CHANGELOG 가이드 + remote push + 셋업 검증 (Phase 5)", - "body_intro": "**README.md** 에 \"Notion 업무일지 — /diary-notion 슬래시 커맨드\" 섹션 추가 (계층 구조 다이어그램, 5분 셋업 가이드, DB 컬럼 표, 보안 주의). **CHANGELOG.md** `[Unreleased]` 에 변경 요약. `git push -u origin feat/diary-notion` 으로 remote에 7 commits push. 사용자가 `claude-diary notion init` 직접 실행하여 token + 실제 Notion 페이지 권한 검증 성공.", - "status": "Testing", - "task_group": "diary-notion-impl", - "depends_on_indices": [7], - "categories": ["docs", "infra", "verify"], - "project": "claude-code-hooks-diary", - "user_prompts": [ - "일단 여기 작업까지 커밋 후 푸쉬하자", - "현재 이렇게 터미널에서 실행되면 되는건가?" - ], - "files_modified": [ - "README.md", - "CHANGELOG.md" - ], - "files_created": [], - "commands_run": [ - "git push -u origin feat/diary-notion", - "claude-diary notion init" - ], - "commit_hashes": ["b48b6ea"], - "errors": [] - }, - { - "title": "Status / Task Group / Depends On 컬럼 설계 갱신 (Phase 6a)", - "body_intro": "노션 행에 **진행도(Status, select 5단계)** + **종속성(Depends On, self-relation 단방향)** + **큰 작업 묶음(Task Group)** 컬럼을 추가하기로 결정. 같은 작업이 며칠에 걸쳐 진행되는 시나리오를 옵션 A(매번 새 task + depends_on) + 옵션 B(Task Group으로 묶음) 조합으로 표현하기로 합의. JSON 스키마와 design.md에 결정 #20~23 추가.", - "status": "Design", - "task_group": "diary-notion-impl", - "depends_on_indices": [8], - "categories": ["design", "schema", "decision"], - "project": "claude-code-hooks-diary", - "user_prompts": [ - "흠 좋은데 혹시, 노션에 기록할 때 진행도도 같이 표현할 수 있나? 그리고 작업들의 전후를 연결할 수 있어? 노션 데이터베이스의 종속성 항목을 통해서 말이야", - "1. 더 자세하게 처리 / 단방향 / c", - "a+b 로 진행하자" - ], - "files_modified": [ - "docs/02-design/features/diary-notion-hierarchical.design.md" - ], - "files_created": [], - "commands_run": [ - "git commit -m \"docs: Status / Task Group / Depends On 컬럼 결정 추가 (Phase 6a)\"" - ], - "commit_hashes": ["120e019"], - "errors": [] - }, - { - "title": "DB 스키마 + self-relation Depends On 추가 (Phase 6b)", - "body_intro": "**`exporters/notion_hierarchical.py::_create_database()`** 에 Status/Task Group 컬럼 추가. DB 생성 직후 **`_add_depends_on_relation()`** 으로 PATCH `/v1/databases/{id}` 호출해 self-referential Depends On relation 컬럼 추가 (database_id가 생성 후에야 알려지므로 2단계 호출 필수). 기존 테스트 update 후 전체 553 통과.", - "status": "Testing", - "task_group": "diary-notion-impl", - "depends_on_indices": [9], - "categories": ["feat", "infrastructure", "test"], - "project": "claude-code-hooks-diary", - "user_prompts": [], - "files_modified": [ - "src/claude_diary/exporters/notion_hierarchical.py", - "tests/test_notion_hierarchical.py" - ], - "files_created": [], - "commands_run": [ - "python -m pytest", - "git commit -m \"feat: Notion DB 스키마에 Status/Task Group + self-relation Depends On 추가 (Phase 6b)\"" - ], - "commit_hashes": ["01737ae"], - "errors": [] - }, - { - "title": "notion-push 2-pass + 슬래시 instructions 갱신 (Phase 6c)", - "body_intro": "`cli/notion_push.py` 를 **2-pass 흐름** 으로 리팩토링 — Pass 1에서 모든 행 생성 후 task_index → row_id 맵 누적, Pass 2에서 `depends_on_indices` 가 있는 task만 PATCH로 relation 채움. `_build_properties` 에 status/task_group 추가 (VALID_STATUSES 검증). 슬래시 instructions에 status 5단계 가이드 + task_group/depends_on_indices 추출 가이드 추가. `install --force` 옵션으로 슬래시 커맨드 파일 강제 갱신. 새 테스트 12개로 전체 565 통과.", - "status": "Testing", - "task_group": "diary-notion-impl", - "depends_on_indices": [10], - "categories": ["feat", "cli", "test"], - "project": "claude-code-hooks-diary", - "user_prompts": [], - "files_modified": [ - "src/claude_diary/cli/notion_push.py", - "src/claude_diary/cli/setup.py", - "src/claude_diary/cli/__init__.py", - "src/claude_diary/exporters/notion_hierarchical.py", - "tests/test_notion_push.py", - "tests/test_setup.py" - ], - "files_created": [], - "commands_run": [ - "python -m pytest", - "git commit -m \"feat: 2-pass push ... (Phase 6c)\"" - ], - "commit_hashes": ["e13971a"], - "errors": [] - }, - { - "title": "Status/Depends On 마이그레이션 + Notion 검증 (Phase 6d)", - "body_intro": "`claude-diary install --force` 로 슬래시 커맨드 파일 갱신 후, 기존 9개 행 archive 하고 `status` / `task_group` / `depends_on_indices` 포함한 13개 task로 재push. Phase 6 작업 자체 (a/b/c/d) 도 같은 push에 포함. Notion DB에서 Status 5단계 표시 + Task Group 으로 group view + Depends On 순차 관계 사슬 확인.", - "status": "Testing", - "task_group": "diary-notion-impl", - "depends_on_indices": [11], - "categories": ["infra", "verify", "migration"], - "project": "claude-code-hooks-diary", - "user_prompts": [], - "files_modified": [], - "files_created": [], - "commands_run": [ - "claude-diary install --force", - "claude-diary notion push --input .diary-notion-migrate1.json --force" - ], - "commit_hashes": [], - "errors": [] - } - ] -} diff --git a/.gitignore b/.gitignore index 56d1a7a..4eb513a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Thumbs.db # Original archive (source already extracted) claude-code-working-diary.tar.gz + +# /diary-notion 임시 JSON (Claude가 cwd에 작성 → CLI가 자동 삭제 / 부분 실패 시 보존) +.diary-notion-*.json From a1e6cb44e92d7072e6bd8a8b2a856d387971570b Mon Sep 17 00:00:00 2001 From: cys Date: Mon, 1 Jun 2026 17:44:26 +0900 Subject: [PATCH 13/30] feat: add codex notion task hierarchy --- .codex-plugin/plugin.json | 36 +++ CHANGELOG.md | 11 +- MANIFEST.in | 2 + README.en.md | 28 ++ README.md | 21 +- .../diary-notion-hierarchical.design.md | 155 ++++++---- pyproject.toml | 1 + skills/diary-notion/SKILL.md | 95 ++++++ skills/diary-notion/agents/openai.yaml | 7 + skills/diary/SKILL.md | 34 +++ skills/diary/agents/openai.yaml | 7 + src/claude_diary/cli/__init__.py | 9 +- src/claude_diary/cli/notion_init.py | 2 +- src/claude_diary/cli/notion_push.py | 128 +++++++-- src/claude_diary/cli/setup.py | 234 ++++++++++++++- src/claude_diary/cli/write.py | 190 ++++++++---- .../exporters/notion_hierarchical.py | 69 +++-- src/claude_diary/formatter.py | 270 ++++++++++++++---- src/claude_diary/i18n.py | 54 ++++ tests/test_cli.py | 5 +- tests/test_codex_plugin.py | 48 ++++ tests/test_formatter.py | 150 ++++++++-- tests/test_notion_hierarchical.py | 98 ++++++- tests/test_notion_push.py | 101 +++++++ tests/test_setup.py | 92 ++++++ tests/test_write.py | 77 +++++ 26 files changed, 1692 insertions(+), 232 deletions(-) create mode 100644 .codex-plugin/plugin.json create mode 100644 skills/diary-notion/SKILL.md create mode 100644 skills/diary-notion/agents/openai.yaml create mode 100644 skills/diary/SKILL.md create mode 100644 skills/diary/agents/openai.yaml create mode 100644 tests/test_codex_plugin.py diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..547459b --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,36 @@ +{ + "name": "working-diary", + "version": "4.2.0", + "description": "Record AI coding sessions as Markdown diaries or task-based Notion entries.", + "author": { + "name": "solzip", + "url": "https://github.com/solzip" + }, + "homepage": "https://github.com/solzip/claude-code-hooks-diary", + "repository": "https://github.com/solzip/claude-code-hooks-diary", + "license": "MIT", + "keywords": [ + "diary", + "productivity", + "work-log", + "notion", + "codex", + "claude-code" + ], + "skills": "./skills/", + "interface": { + "displayName": "Working Diary", + "shortDescription": "Record Codex work sessions to Markdown or Notion.", + "longDescription": "Working Diary adds Codex skills for writing manual Markdown diary entries and pushing task-sized session records to a hierarchical Notion database. It keeps project, purpose, task group, branch, status, categories, files, commands, commits, and dependencies available as filterable Notion columns.", + "developerName": "solzip", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Write" + ], + "defaultPrompt": [ + "Use $diary to record this session.", + "Use $diary-notion to push this session to Notion." + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2330111..a8057ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,14 +16,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - 에러 분기: 401/403 fail fast, 400 skip, 429/5xx retry, 404 자동 재생성 - **`claude-diary notion init`**: 대화형 셋업 (token + 페이지 URL/ID + 권한 검증) - **`claude-diary notion push --input ` `--force`**: 임시 JSON 파일 받아 Notion에 push -- **DB 자동 생성 스키마**: Name, Date, Project, Branch, Categories, Files, Commits, Lines, Session ID, Task Index +- **Codex 표준 지원**: `$diary`, `$diary-notion` skills + `.codex-plugin/plugin.json` +- **중립 CLI alias**: `working-diary` 명령을 `claude-diary`와 동일하게 제공 +- **DB 자동 생성 스키마**: Name, Date, Project, Purpose, Branch, Status, Task Group, Parent Task, Categories, Files, Commits, Lines, Depends On, Session ID, Task Index +- **Notion 작업 DB 관계 구조**: `Parent Task` self-relation을 추가해 포함 관계를 DB 컬럼에 기록하고, 기존 `Depends On`은 선행 관계로 유지 +- **접힌 근거 중심 Notion 본문**: page body를 callout/checklist/toggle 기반으로 압축해 상단은 요약과 상태, 부록은 코드·파일·명령어·Git·원문 요청 근거로 분리 +- **`claude-diary write --input `**: Codex skill이 생성한 JSON으로 수동 Markdown 일지 작성 - **`lib/notion_cache.py`**: 연도 페이지/DB/행 ID 캐시 (root_page_id 변경 시 자동 무효화) - **`lib/git_info.py` 확장**: `get_branch_for_commit`, `get_head_branch`, `get_commit_info`, `get_diff_stat_for_commits` -- **새 테스트 90개** (전체 553 통과) +- **테스트 보강**: Notion Purpose, Codex plugin/skills, Codex JSON input 경로 검증 (전체 583 통과) ### Changed - `cli/setup.py` 일반화: `SLASH_COMMANDS` dict로 다중 슬래시 커맨드 관리 -- `formatter.py`: Notion API blocks 빌더 (`build_notion_blocks`) 추가 +- `formatter.py`: Notion API blocks 빌더 (`build_notion_blocks`) 추가 및 의미 섹션 렌더링 보강 - 설계 문서: [`docs/02-design/features/diary-notion-hierarchical.design.md`](docs/02-design/features/diary-notion-hierarchical.design.md) ## [4.1.0] - 2026-03-17 (Phase D) diff --git a/MANIFEST.in b/MANIFEST.in index 15bbabd..ee03dfa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,5 @@ include CHANGELOG.md include SECURITY.md include README.md include README.en.md +recursive-include .codex-plugin *.json +recursive-include skills *.md *.yaml diff --git a/README.en.md b/README.en.md index b789c50..a69fd29 100644 --- a/README.en.md +++ b/README.en.md @@ -114,9 +114,36 @@ For when you want to record an entry mid-session without waiting for the Stop Ho **Usage:** - Inside a Claude Code session: type `/diary` — reads the current cwd's transcript and writes the entry +- Inside a Codex session: type `$diary` — writes the current conversation/tool context through the same manual diary path - Or from the terminal: `claude-diary write` `claude-diary install` installs `~/.claude/commands/diary.md` so `/diary` works in every project. Re-run it once if you installed before this feature shipped (it's idempotent). `claude-diary uninstall` removes it (preserves user-modified files). +Codex skills can be installed from the Codex plugin in this repo or with `claude-diary install --codex`. + +## Notion Work Diary — `/diary-notion` / `$diary-notion` + +Push the current session to a hierarchical Notion database as task-sized rows. Use `/diary-notion` in Claude Code and `$diary-notion` in Codex. + +``` +[Notion root page: "Working Diary"] + └── 2026 (auto-created) + └── Entries (inline DB, auto-created) + ├── "Decide Notion DB schema" | Project: claude-diary | Purpose: Planning + ├── "Refactor git_info.py" | Project: claude-diary | Purpose: Refactor + └── ... +``` + +Rows include filterable/groupable/relational columns for `Project`, `Purpose`, `Task Group`, `Parent Task`, `Depends On`, `Branch`, `Status`, and `Categories`. Automatic Notion view creation is intentionally left as a later step. +`Parent Task` represents containment, while `Depends On` represents prerequisite order. Each Notion page body stays compact with callouts/checklists, and developer evidence such as code changes, files, commands, Git, and original prompts is hidden in appendix toggles. Code changes are high-signal summaries, not full diffs; include only behavior, schema, CLI, user workflow, or verification-scope changes. +Titles and narrative body content are written in Korean. File paths, commands, branches, commit hashes, code identifiers, and `Purpose`/`Status` enum values remain literal or English. + +```bash +claude-diary notion init +/diary-notion # Claude Code +$diary-notion # Codex +``` + +Purpose values use stable English labels: `Feature`, `Bugfix`, `Refactor`, `Docs`, `Test`, `Infra`, `Planning`, `Research`, `Review`, `Release`, `Support`, `Maintenance`, `General`. ## Diary Example @@ -177,6 +204,7 @@ export CLAUDE_DIARY_TZ_OFFSET="-5" # EST (UTC-5) ```bash claude-diary write # Write current session diary on demand (also via `/diary` slash command) +working-diary write # Neutral alias for the same CLI claude-diary search "keyword" # Keyword search claude-diary filter --project my-app # Filter by project claude-diary trace src/main.py # File change history diff --git a/README.md b/README.md index 081ef39..c004b5b 100644 --- a/README.md +++ b/README.md @@ -114,24 +114,28 @@ cd claude-code-hooks-diary/working-diary-system **사용법:** - Claude Code 세션에서 `/diary` 입력 → 현재 cwd의 transcript를 읽고 기록 +- Codex 세션에서 `$diary` 입력 → 현재 대화/도구 사용 내역을 JSON으로 정리해 같은 경로에 기록 - 또는 터미널에서 `claude-diary write` `claude-diary install` 시 `~/.claude/commands/diary.md`가 함께 설치되어 모든 프로젝트에서 `/diary` 사용 가능. 이미 설치한 적 있다면 한 번 더 실행해서 슬래시 커맨드만 추가하세요 (멱등). `claude-diary uninstall` 시 함께 제거됩니다 (사용자가 수정한 파일은 보존). +Codex skill은 repo의 Codex plugin으로 설치하거나 `claude-diary install --codex`로 `~/.codex/skills`에 설치할 수 있습니다. -## Notion 업무일지 — `/diary-notion` 슬래시 커맨드 +## Notion 업무일지 — `/diary-notion` / `$diary-notion` -현재 세션을 **작업 단위로 분리**해 Notion DB에 push합니다. Claude Code 구독으로 동작하며 **별도 Anthropic API 키 필요 없음**, Notion 무료 플랜에서도 동작. +현재 세션을 **작업 단위로 분리**해 Notion DB에 push합니다. Claude Code에서는 `/diary-notion`, Codex에서는 `$diary-notion`을 사용합니다. 별도 LLM API 키 없이 현재 에이전트 세션 컨텍스트로 동작하며, Notion 무료 플랜에서도 동작. ``` [Notion 루트 페이지: "Working Diary"] └── 📄 2026 (자동 생성) └── 🗄️ Entries (인라인 DB, 자동 생성) ├── "Notion DB 컬럼 스키마 결정" | Project: claude-diary | Branch: feat/notion - ├── "git_info.py 리팩토링" | Project: claude-diary | Branch: feat/notion + ├── "git_info.py 리팩토링" | Project: claude-diary | Purpose: Refactor └── ... ``` -한 세션의 의논/구현이 의미 단위로 N개 행으로 분리되어 들어갑니다. branch가 바뀌면 무조건 새 task로 분리. +한 세션의 의논/구현이 의미 단위로 N개 행으로 분리되어 들어갑니다. branch가 바뀌면 무조건 새 task로 분리. `Project`, `Purpose`, `Task Group`, `Parent Task`, `Depends On` 컬럼으로 Notion에서 필터/그룹/관계 조회가 가능하며, view 자동 생성은 후속 단계로 분리합니다. +`Parent Task`는 포함 관계, `Depends On`은 선행 관계를 나타냅니다. 각 Notion 페이지 본문은 짧은 callout/checklist 중심으로 정리하고, 코드 변경·파일·명령어·Git·원문 요청은 접힌 부록(toggle)에 기록합니다. 코드 변경은 full diff가 아니라 동작/스키마/CLI/사용자 흐름/검증 범위를 바꾼 주요 변경만 남깁니다. +제목과 설명형 본문은 한국어로 기록하고, 파일 경로/명령어/branch/commit hash/코드 식별자 및 `Purpose`, `Status` enum 값은 원문 또는 영어 값을 유지합니다. ### 5분 셋업 @@ -143,7 +147,7 @@ cd claude-code-hooks-diary/working-diary-system claude-diary notion init ``` 대화형으로 token과 root page URL(또는 ID)을 입력하면 권한 검증 후 config에 저장됩니다. -5. **세션에서 `/diary-notion` 입력** — 작업 분리 + Notion push 자동 실행 +5. **세션에서 `/diary-notion` 또는 `$diary-notion` 입력** — 작업 분리 + Notion push 자동 실행 ### 사용법 @@ -153,6 +157,7 @@ claude-diary notion init # 매 세션 /diary-notion # Claude Code 세션 안에서 +$diary-notion # Codex 세션 안에서 # 같은 세션 다시 push (실수 등): # 기본은 skip (Session ID + Task Index로 멱등성) @@ -166,7 +171,12 @@ claude-diary notion init | Name | title | Claude가 뽑은 task 제목 (명사구) | | Date | date | | | Project | select | cwd 폴더명. group/filter용 | +| Purpose | select | Feature/Bugfix/Refactor/Docs/Test/Infra/Planning/Research/Review/Release/Support/Maintenance/General | | Branch | select | task별 branch (group/filter용) | +| Status | select | Discussion/Design/Implementation/Testing/Deployed | +| Task Group | select | 며칠/여러 세션에 걸치는 큰 작업 묶음 | +| Parent Task | relation | 같은 DB의 상위 작업. 하위항목/sub-item view 자동화의 기반 | +| Depends On | relation | 같은 DB의 선행 작업 | | Categories | multi_select | design/refactor/bugfix/... 자유 라벨 | | Files | number | 수정+생성 파일 수 | | Commits | number | task별 commit 수 | @@ -239,6 +249,7 @@ export CLAUDE_DIARY_TZ_OFFSET="9" ```bash claude-diary write # 현재 세션 작업일지를 즉시 기록 (`/diary` 슬래시 커맨드로도 호출) +working-diary write # 동일한 CLI의 중립 alias claude-diary search "키워드" # 키워드 검색 claude-diary filter --project my-app # 프로젝트 필터 claude-diary trace src/main.py # 파일 변경 이력 diff --git a/docs/02-design/features/diary-notion-hierarchical.design.md b/docs/02-design/features/diary-notion-hierarchical.design.md index bf3f141..40e41d3 100644 --- a/docs/02-design/features/diary-notion-hierarchical.design.md +++ b/docs/02-design/features/diary-notion-hierarchical.design.md @@ -11,8 +11,8 @@ | 관점 | 내용 | |------|------| | **Problem** | 기존 NotionExporter는 단순 flat DB push만 가능. 세션 1개 = 행 1개. 업무일지로 보기에 부적합 | -| **Solution** | `/diary-notion` 슬래시 커맨드 — Claude가 세션을 작업 단위로 분리 → 연도별 페이지/단일 DB로 push | -| **Core Value** | 별도 API 키 없이 (Claude Code 구독만으로) 업무일지 자동화 | +| **Solution** | `/diary-notion`(Claude) / `$diary-notion`(Codex) — 에이전트가 세션을 작업 단위로 분리 → 연도별 페이지/단일 DB로 push | +| **Core Value** | 별도 LLM API 키 없이 현재 에이전트 세션 컨텍스트로 업무일지 자동화 | --- @@ -22,13 +22,13 @@ - 한 세션의 작업을 **의미 단위로 N개 행**으로 분리 - **연도별 페이지 → 단일 통합 DB** 구조 (단순) -- Claude Code 구독만으로 동작 (별도 Anthropic API 키 불필요) +- Claude Code/Codex 세션 컨텍스트만으로 동작 (별도 LLM API 키 불필요) - Notion 무료 플랜에서도 동작 - 기존 `exporters.notion` config 재사용 ### 1.2 Design Principles -- **Claude는 의미 분석만, CLI는 기계 처리만** — 역할 분리 +- **에이전트는 의미 분석만, CLI는 기계 처리만** — 역할 분리 - **무료 path 우선** — 외부 의존성 최소화 - **소프트 멱등** — 실수로 두 번 눌러도 데이터 깨지지 않음 @@ -74,21 +74,27 @@ | 컬럼 | 타입 | 표시 | 값 예시 | 용도 | |------|------|------|---------|------| -| Name | title | ✅ | "DB 컬럼 스키마 의논" | Claude가 뽑은 task 제목 | +| Name | title | ✅ | "DB 컬럼 스키마 의논" | 에이전트가 뽑은 task 제목 | | Date | date | ✅ | 2026-05-26 | 정렬/필터/캘린더 뷰 | | Project | select | ✅ | claude-diary | group/filter | +| Purpose | select | ✅ | Feature | 목적별 group/filter | | Branch | select | ✅ | feat/diary-notion | group/filter. CLI가 자동 채움 | | Status | select | ✅ | Implementation | 5단계: Discussion/Design/Implementation/Testing/Deployed | -| Task Group | select | ✅ | diary-notion-impl | 큰 작업 단위 묶음. Claude가 추출 | +| Task Group | select | ✅ | diary-notion-impl | 큰 작업 단위 묶음. 에이전트가 추출 | | Categories | multi_select | ✅ | design, notion | 작업 성격 | | Files | number | ✅ | 7 | 수정+생성 파일 수 | | Commits | number | ✅ | 3 | 커밋 개수 | | Lines | number | ✅ | 142 | 추가+삭제 합 | -| Depends On | relation (self) | ✅ | → 다른 행 | 선행 작업 참조 (단방향) | +| Parent Task | relation (self) | ✅ | → 상위 행 | 포함 관계. 하위항목/sub-item view 자동화의 기반 | +| Depends On | relation (self) | ✅ | → 선행 행 | 선행 작업 참조 (단방향) | | Session ID | rich_text | 🔒 hidden | "abc-123-def" | 멱등성 키 | | Task Index | number | 🔒 hidden | 0, 1, 2 | 멱등성 키 | -→ 표시 11개 + hidden 2개 = 총 13개. 의미 요약은 컬럼이 아닌 본문(`body_intro`)으로만 노출. +→ 표시 13개 + hidden 2개 = 총 15개. 의미 요약은 컬럼이 아닌 compact body(`body_intro` + callout/checklist/toggle 부록)로 노출. + +**Purpose (select)**: +- 영어 enum 사용: `Feature`, `Bugfix`, `Refactor`, `Docs`, `Test`, `Infra`, `Planning`, `Research`, `Review`, `Release`, `Support`, `Maintenance`, `General` +- Notion에서 목적별 필터/그룹을 보장하는 1차 분류. 자동 view 생성은 후속 단계로 분리. **Status 5단계 (select)**: - `Discussion`: 의논만, 결정 미완 (드물게) @@ -97,9 +103,11 @@ - `Testing`: 테스트 작성/검증 완료 - `Deployed`: 머지/배포까지 완료 -한 task에 여러 단계가 섞이면 **가장 진행된 단계로**. Claude가 transcript 보고 판단. +한 task에 여러 단계가 섞이면 **가장 진행된 단계로**. 에이전트가 세션 컨텍스트를 보고 판단. + +**Task Group (select)**: 며칠/여러 세션에 걸치는 큰 작업 단위 묶음. 에이전트가 첫 task 시점에 새 그룹명 생성, 이전 작업의 연속이면 같은 그룹명 사용. 일관성 보장은 어렵지만 group view로 묶어 보는 편의가 핵심 가치. -**Task Group (select)**: 며칠/여러 세션에 걸치는 큰 작업 단위 묶음. Claude가 첫 task 시점에 새 그룹명 생성, 이전 작업의 연속이면 같은 그룹명 사용. 일관성 보장은 어렵지만 group view로 묶어 보는 편의가 핵심 가치. +**Parent Task (self-relation)**: 포함 관계. 예: `상품 목록 포커싱`의 Parent Task는 `로컬 테스트 진행`. 같은 push 안에서는 JSON의 `parent_index`를 row ID로 변환해 연결한다. 너무 작은 확인 항목은 별도 row가 아니라 본문 checklist로 남긴다. **Depends On (self-relation, 단방향)**: 같은 DB 안의 다른 행 참조. JSON 스키마의 `depends_on_indices` 가 같은 push의 task index를 가리킴. CLI가 push 순서대로 row_id 누적 → 인덱스를 실제 row ID로 변환해서 relation 채움. Notion이 자동 reverse view 제공해 단방향 정의로 양방향 효과. @@ -111,35 +119,38 @@ ### 3.2 Layer 2 — Page Body (행 클릭 시 보이는 markdown) ```markdown -[body_intro - Claude가 작성한 1~3문장 의미 요약] +[callout] body_intro - 에이전트가 작성한 1~3문장 의미 요약 -## 사용자 요청 -- "..." -- "..." +## 요약 +[callout] 결과/의미 요약 -## 작업 요약 -- ... -- ... +## 작업 한눈에 +[callout] 배경/범위/접근/결과 -## 수정/생성 파일 -- src/... -- src/... +## 영향 +[callout] 사용자/운영/제품/개발 품질 영향 -## 실행한 명령 -- git log --oneline -- ... +## 검증 및 상태 +[checked todo] 실행한 검증 +[callout] 남은 리스크 -## Git 변경사항 -**Branch**: main -- `abc1234` feat: ... -- `def5678` test: ... -- (lines: +142 / -38) +## 다음 액션 +[unchecked todo] 후속 작업 -## 발생한 에러 -(있을 때만 표시) +## 부록 +[toggle] 개발 근거: 주요 변경, 주요 코드 변경, 파일, 명령어, Git, 이슈 +[toggle] 원문 요청: user_prompts 원문 ``` -**조립**: Claude가 만든 `body_intro` 1~3문장 + CLI가 raw 데이터로 조립한 기계 섹션들. +**조립**: 에이전트가 만든 `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `risks`, `next_steps`, `support_needed` + CLI가 코드/파일/명령/Git raw 데이터를 접힌 부록(toggle)으로 조립한다. 코드 변경은 full diff가 아니라 주요 변경만 기록한다. + +**언어 정책**: `title`과 설명형 본문 필드(`body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `risks`, `next_steps`, `support_needed`)는 한국어로 작성한다. 파일 경로, 명령어, branch, commit hash, 코드 식별자, 함수/클래스명, `Purpose`/`Status` enum 값은 원문 또는 영어 값을 유지한다. + +**본문 보고 원칙**: +- DB relation이 구조를 담당하고, page body는 짧은 상태와 근거를 담당한다. +- `요약`, `작업 한눈에`, `영향`, `검증 및 상태`, `다음 액션`, `부록` 순서로 배치한다. +- 주요 코드 변경과 파일/명령/Git/오류는 핵심 메시지가 아니라 근거이므로 접힌 `부록`에 둔다. +- Notion API child block 100개 제한을 넘지 않도록 렌더링 한도를 보수적으로 둔다. --- @@ -152,8 +163,24 @@ { "title": "Notion DB 컬럼 스키마 결정", "body_intro": "DB 컬럼을 Layer 1/2로 분리. 단일 통합 DB + Project select 채택. summary 컬럼은 본문 첫 문단(body_intro)으로 통합.", + "summary_hints": ["Project/Purpose/Task Group 기준으로 필터 가능한 DB 구조를 확정"], + "key_changes": ["Project/Purpose/Task Group 기준 필터와 그룹을 컬럼으로 보장"], + "work_context": ["프로젝트/목적별로 작업을 찾기 어렵던 기존 flat DB 구조를 개선하기 위해 시작"], + "work_scope": ["Notion DB 컬럼과 본문 렌더링 정책을 함께 정리"], + "approach": ["컬럼은 필터/그룹/관계 구조를 담당하고 본문은 compact body로 읽히게 분리"], + "outcome": ["행 하나만 열어도 작업 배경, 영향, 검증, 후속 조치를 파악할 수 있게 됨"], + "impact": ["상사 보고와 개발자 회고에 모두 쓸 수 있는 단일 작업 문서가 됨"], + "code_change_highlights": ["`formatter.py`: Notion page body에 주요 변경/검증/리스크 섹션을 선택 렌더링"], + "decisions": ["view 자동화는 후속 단계로 분리"], + "implementation_notes": ["단순 파일 목록보다 개발자가 이어서 볼 수 있는 작업 기록을 우선"], + "verification": ["formatter 단위 테스트로 섹션 렌더링 검증"], + "risks": ["기존 설치된 slash command/skill은 force refresh 전까지 예전 지시문을 사용할 수 있음"], + "next_steps": ["사용자 환경에 최신 Codex skill 설치"], + "support_needed": [], "status": "Design", "task_group": "diary-notion-impl", + "purpose": "Planning", + "parent_index": null, "depends_on_indices": [], "categories": ["design", "notion"], "project": "claude-code-hooks-diary", @@ -168,10 +195,10 @@ } ``` -### 4.1 Claude의 책임 (슬래시 커맨드 instructions) +### 4.1 에이전트의 책임 (Claude slash command / Codex skill instructions) - transcript를 작업 단위로 분리 -- 각 task의 `title` (30~50자 명사구), `body_intro` (1~3문장 평어체, 결과 중심) +- 각 task의 `title` (30~50자 명사구), `body_intro` (1~3문장 평어체, 결과 중심), `summary_hints`/`key_changes`/`code_change_highlights`/`decisions`/`implementation_notes`/`verification`/`risks`/`next_steps`, `parent_index`/`depends_on_indices` - `categories` 추출 - `user_prompts`, `files_modified`, `files_created`, `commands_run`, `errors` 추출 - `commit_hashes`를 task에 매핑 @@ -180,18 +207,18 @@ - `commit_hashes`로 git 메타 수집 (message, lines, branch) — `git_info.py` 재사용 - task별 Branch 자동 결정 (commit 있으면 첫 commit의 branch, 없으면 HEAD branch) -- Layer 2 body markdown 조립 (`body_intro` + raw 섹션) — `formatter.py` 확장 +- Layer 2 body 조립 (`body_intro` + callout/checklist/toggle 부록) — `formatter.py` 확장 - 연도 페이지/DB 자동 생성 (없으면) - 행 추가 (멱등성 처리 포함) - 캐시 갱신 ### 4.3 JSON 전달 방식 — 임시 파일 via cwd -**선택**: Claude가 cwd에 임시 JSON 파일을 작성 → CLI에 `--input` 으로 경로 전달. +**선택**: 에이전트가 cwd에 임시 JSON 파일을 작성 → CLI에 `--input` 으로 경로 전달. ``` -1. Claude: Write 도구로 cwd에 `.diary-notion-.json` 작성 -2. !`claude-diary notion-push --input .diary-notion-.json` +1. 에이전트: cwd에 `.diary-notion-.json` 작성 +2. !`claude-diary notion push --input .diary-notion-.json` 3. CLI: 파일 read → push → try/finally로 파일 삭제 (성공/실패 무관) 4. (보험) 슬래시 커맨드 마지막에서 한 번 더 삭제 시도 ``` @@ -213,14 +240,14 @@ /diary-notion 입력 │ ▼ -[Claude (현재 세션)] ── Claude Code 구독으로 동작 +[Agent (현재 세션)] ── Claude Code/Codex 세션 컨텍스트로 동작 ├─ transcript 분석 ├─ 작업 단위 N개 분리 - ├─ 각 task: title / body_intro / summary / categories / prompts / files / commands / commit_hashes + ├─ 각 task: title / body_intro / summary_hints / key_changes / work_context / work_scope / approach / outcome / impact / code_change_highlights / decisions / implementation_notes / verification / risks / next_steps / support_needed / parent_index / depends_on_indices / categories / prompts / files / commands / commit_hashes └─ JSON 생성 │ - ▼ !`claude-diary notion-push --stdin` -[CLI: notion-push 명령] + ▼ !`claude-diary notion push --input .diary-notion-.json` +[CLI: notion push 명령] ├─ JSON 파싱 ├─ commit_hashes → git_info.py로 메타+lines 수집 ├─ Notion API 호출: @@ -352,21 +379,22 @@ Saved to: /config.json | 1 | 계층 구조 | A: 연도 → Entries DB → 행 | 연말 회고 자료로 한 페이지에 다 보임 | | 2 | DB 분리 | 단일 DB + Project select | 새 프로젝트마다 DB 생성 X. Notion view로 분류 | | 3 | DB 컬럼 | 8개 표시 + 2개 hidden | 답답하지 않으면서 멱등성 키 확보 | -| 4 | 작업 분리 | LLM (옵션 3: 슬래시 커맨드 안의 Claude) | API 키 X, 의미 단위 분리 가능 | -| 5 | LLM 호출 위치 | 슬래시 커맨드 = 현재 세션의 Claude | Claude Code 구독으로 무료. SDK 의존성 X | -| 6 | 본문 markdown | C: Claude의 intro + CLI의 raw 섹션 | 의미 정리 + 일관성 동시 확보 | +| 4 | 작업 분리 | 현재 에이전트 세션의 LLM | API 키 X, 의미 단위 분리 가능 | +| 5 | LLM 호출 위치 | Claude slash command 또는 Codex skill | 별도 SDK 의존성 X | +| 6 | 본문 markdown | C: 에이전트의 intro + CLI의 raw 섹션 | 의미 정리 + 일관성 동시 확보 | | 7 | git 정보 수집 | A: CLI가 자체 수집 | 정확도 ↑, `git_info.py` 재사용 | | 8 | 멱등성 | B + `--force`: skip 기본, force는 archive&recreate | 실수 방지 + 강제 갱신 옵션 | | 9 | 셋업 흐름 | B: `notion init` 대화형 명령 + URL 파싱 + token/read 검증 | 첫 인상 비용 ↓, page_id 헷갈림 해결, 권한 디버깅 비용 ↓ | -| 10 | 작업 분리 우선순위 | B: Semantic-first (의미 단위) | 의논 세션도 풍부, 큰 commit 안 묶임, Claude 정리 능력 활용 | +| 10 | 작업 분리 우선순위 | B: Semantic-first (의미 단위) | 의논 세션도 풍부, 큰 commit 안 묶임, 에이전트 정리 능력 활용 | | 11 | Title 형식 | 명사구, 30~50자, 시제/주어/prefix/마침표 없음 | DB 뷰 한 줄에 들어감. 일관성 | | 12 | Body intro 톤 | 평어체, 1~3문장, 결과 중심, markdown 강조 OK, 추측 금지 | 글로벌 지침과 일관. 회고 시 빠른 회상 | -| 13 | summary 컬럼 | 삭제 — `body_intro` 만 유지 | 사용자가 본문 위주로 보기 때문. 중복 제거 | +| 13 | summary 컬럼 | 삭제 — 의미 요약은 compact body 섹션으로 유지 | 컬럼 중복 없이 요약/상태/검증/근거를 page body에 남김 | | 14 | JSON 전달 방식 | 임시 파일 (cwd, `.diary-notion-.json`) | PowerShell 호환. escape 문제 회피. 디버깅 쉬움 | | 20 | Status 컬럼 | select, 5단계 (Discussion/Design/Implementation/Testing/Deployed) | 진행도 시각화. 한 task 안에 여러 단계 섞이면 가장 진행된 단계 | | 21 | Depends On 컬럼 | self-relation, 단방향 | 작업 순서 시각화. Notion이 reverse view 자동 제공 | -| 22 | Task Group 컬럼 | select. Claude가 task별로 추출 | 며칠/여러 세션에 걸치는 큰 작업을 group view로 묶기 | -| 23 | 멱등성 + 새 컬럼 마이그레이션 | 기존 행 archive(`--force`) 후 새 스키마로 재push | Status/Depends On/Task Group 소급 채움 | +| 22 | Parent Task 컬럼 | self-relation, 단방향 | 포함 관계를 DB에 보존하고 후속 sub-item view 자동화의 기반으로 사용 | +| 22 | Task Group 컬럼 | select. 에이전트가 task별로 추출 | 며칠/여러 세션에 걸치는 큰 작업을 group view로 묶기 | +| 23 | 멱등성 + 새 컬럼 마이그레이션 | 기존 행 archive(`--force`) 후 새 스키마로 재push | Status/Depends On/Parent Task/Task Group 소급 채움 | | 15 | Branch 컬럼 추가 + 경계 룰 | Branch select 컬럼 + "branch 다르면 task 분리"를 최우선 분리 룰로 | 한 task = 한 branch 보장. select 컬럼이 의미 있어짐. 여러 branch 섞이는 케이스 자동 해결 | | 16 | Error 종류별 분기 | 401/403 fail fast, 400 skip, 429/5xx retry, 404 자동 재생성 | 의미 없는 retry 방지. 캐시 일관성 자동 복구 | | 17 | Retry 정책 | 인라인 retry 3회 (exponential backoff) + JSON 파일 보존 | 수동 명령에 동기적 보고. queue 안 씀 | @@ -407,12 +435,39 @@ allowed-tools: - **commit이 0개인 의논 세션도 정상** — 의미 단위로 task N개 생성 3. **각 task별 추출** + - 언어 정책: + - `title`, `body_intro`, `summary_hints`, `key_changes`, `decisions`, `implementation_notes`, `verification`, `risks`, `next_steps` 같은 설명형 필드는 반드시 한국어로 작성 + - `status`, `purpose` enum 값은 지정된 영어 값을 그대로 사용 + - 파일 경로, 명령어, branch, commit hash, 코드 식별자, 함수/클래스명은 원문 그대로 유지 + - `user_prompts`는 사용자가 말한 원문을 증거로 보존 - `title`: 30~50자 명사구. 시제/주어/prefix/마침표 없음 - ✅ "Notion DB 컬럼 스키마 결정", "git_info.py 리팩토링" - ❌ "오늘 DB 의논했다", "[설계] DB 컬럼" - `body_intro`: 1~3문장, 200~500자, 평어체, 결과 중심 - transcript에 없는 내용 추가 금지 (추측 X) - markdown 강조(`**굵게**`, `` `코드` `` ) 사용 OK + - Notion 작업 DB 기록처럼 작성. 구조는 DB relation으로 남기고, 본문은 중복 없이 간결하게 쓸 것 + - `summary_hints`: 작업 결과/의미 요약 최대 4개. 단순 파일 나열이 아니라 무엇이 달라졌는지 기록 + - `key_changes`: 개발자가 이 일지만 봐도 흐름을 이해할 수 있는 주요 변경사항 최대 4개 + - `work_context`: 왜 이 작업을 시작했는지 0~1개 + - `work_scope`: 무엇을 바꿨는지 0~1개 + - `approach`: 어떻게 해결했는지 0~1개 + - `outcome`: 결과가 무엇인지 0~1개 + - `impact`: 사용자/운영/제품/개발 품질 영향 0~4개 + - `code_change_highlights`: 실제 코드 변화 중 중요한 것만 0~5개 + - 파일/함수/명령 단위 + 동작상 의미를 함께 기록 + - full diff, 단순 포맷팅, import 정리, 문구 수정, fixture 보정은 제외 + - 동작/스키마/CLI/사용자 흐름/검증 범위가 바뀐 코드는 포함 + - `decisions`: 사용자가 결정했거나 구현 중 확정한 선택지/트레이드오프 0~3개 + - `implementation_notes`: 코드 변경 요약에 넣기 애매한 제약/호환성/마이그레이션 메모 0~4개 + - `verification`: 실행한 테스트, 검증 결과, 검증하지 못한 이유 0~4개 + - `risks`: 주의사항, 남은 리스크, 운영/사용 시 헷갈릴 수 있는 점 0~3개 + - `next_steps`: 남은 작업이나 후속 단계 0~3개 + - `support_needed`: 필요한 결정/지원이 있으면 0~2개 + - `status`: `Discussion` / `Design` / `Implementation` / `Testing` / `Deployed` + - `purpose`: `Feature` / `Bugfix` / `Refactor` / `Docs` / `Test` / `Infra` / `Planning` / `Research` / `Review` / `Release` / `Support` / `Maintenance` / `General` + - `task_group`: 며칠/여러 세션에 걸치는 큰 작업 단위 식별자 + - `depends_on_indices`: 같은 push 안에서 의존하는 선행 task 인덱스 배열 - `categories`: 1~3개. design/refactor/bugfix/test/docs/infra/discussion 같은 자유 라벨 - `project`: 현재 cwd의 폴더명 - `user_prompts`, `files_modified`, `files_created`, `commands_run`, `errors` @@ -420,7 +475,7 @@ allowed-tools: 4. **JSON 출력 및 CLI 호출** - cwd에 `.diary-notion-<8자리>.json` 작성 (Write 도구) - - `!claude-diary notion-push --input .diary-notion-<8자리>.json` 실행 + - `!claude-diary notion push --input .diary-notion-<8자리>.json` 실행 - 종료 후 파일 삭제 ## JSON 형식 @@ -457,7 +512,7 @@ CLI 결과를 그대로 보여주고 push/skip된 task 요약. ### 11.2 신규 -- `cli/notion_push.py` — `claude-diary notion-push --stdin` 명령 +- `cli/notion_push.py` — `claude-diary notion push --input` 명령 - `exporters/notion.py` 안에 `_hierarchical_export()` 추가 (또는 `NotionHierarchicalExporter` 클래스 분리) - `lib/notion_cache.py` — 캐시 read/write - `~/.claude/commands/diary-notion.md` — 슬래시 커맨드 instructions diff --git a/pyproject.toml b/pyproject.toml index be9b31d..c5ab4b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dev = ["pytest>=7.0", "pytest-cov>=4.0"] [project.scripts] claude-diary = "claude_diary.cli:main" +working-diary = "claude_diary.cli:main" [project.urls] Homepage = "https://github.com/solzip/claude-code-hooks-diary" diff --git a/skills/diary-notion/SKILL.md b/skills/diary-notion/SKILL.md new file mode 100644 index 0000000..d111648 --- /dev/null +++ b/skills/diary-notion/SKILL.md @@ -0,0 +1,95 @@ +--- +name: diary-notion +description: Push the current Codex work session to the hierarchical Notion working diary DB. Use when the user invokes $diary-notion or asks Codex to record the current session in Notion by project, purpose, task group, branch, status, categories, files, commands, commits, and dependencies. +--- + +# Diary Notion + +Split the current Codex session into task-sized entries and push them to Notion. + +## Workflow + +1. Review the current conversation, tool calls, git branch, and relevant git commits. +2. Split work into task-sized database rows. Branch changes are hard task boundaries; within a branch, split by semantic work unit. + - Create a row for work that has its own status, evidence, code/test output, or can block another task + - Keep tiny check items, raw notes, long SQL/JS snippets, and reference links inside the page body evidence instead of making them separate rows + - Use `parent_index` for containment hierarchy and `depends_on_indices` for prerequisite order; do not mix the two +3. For each task, produce: + - Language policy: + - Write `title`, `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `risks`, `next_steps`, and `support_needed` in Korean + - Keep `status` and `purpose` as the exact English enum values below + - Preserve file paths, commands, branches, commit hashes, code identifiers, function names, and class names as written + - Preserve `user_prompts` in the user's original wording as evidence + - `title`: concise Korean noun phrase, no prefix or period + - `body_intro`: 1-3 factual Korean sentences based only on observed work + - Write it like a Notion task database record: compact top summary, structured relations in DB properties, and raw evidence hidden in the page body appendix + - `summary_hints`: up to 3 outcome-focused bullets that explain what changed and why it matters + - `key_changes`: up to 3 major behavior/schema/workflow changes a developer can understand without opening the diff + - `work_context`: 0-1 bullet explaining why this work started + - `work_scope`: 0-1 bullet explaining what changed + - `approach`: 0-1 bullet explaining how it was solved + - `outcome`: 0-1 bullet explaining the resulting state + - `impact`: 0-3 user, operations, product, or engineering-quality impacts + - `code_change_highlights`: 0-3 important code changes only + - Include file/function/command scope plus runtime or developer-facing meaning + - Exclude full diffs, formatting-only edits, import cleanup, wording-only edits, and fixture-only noise + - Include changes to behavior, schema, CLI flow, user workflow, or verification scope + - `decisions`: 0-3 decisions or tradeoffs made by the user or settled during implementation + - `implementation_notes`: 0-4 constraints, compatibility notes, migrations, or details that do not fit code highlights + - `verification`: 0-3 tests/checks run, results, or explicit reasons checks were not run + - `risks`: 0-2 cautions, remaining risks, or usage/operation notes + - `next_steps`: 0-2 remaining follow-ups + - `support_needed`: 0-1 decisions or support needed from others + - `status`: `Discussion`, `Design`, `Implementation`, `Testing`, or `Deployed` + - `purpose`: `Feature`, `Bugfix`, `Refactor`, `Docs`, `Test`, `Infra`, `Planning`, `Research`, `Review`, `Release`, `Support`, `Maintenance`, or `General` + - `task_group`: stable kebab-case/snake-case group for multi-session work + - `parent_index`: zero-based index of the parent task in this push, or `null`; use it for "part of" hierarchy + - `depends_on_indices`: zero-based indices in this push, or `[]` + - Use this only when the current task cannot proceed before another task is done + - `categories`, `project`, `user_prompts`, `files_modified`, `files_created`, `commands_run`, `commit_hashes`, `errors` +4. Create `.diary-notion-<8-random>.json` in cwd: + +```json +{ + "session_id": "", + "tasks": [ + { + "title": "...", + "body_intro": "...", + "summary_hints": ["..."], + "key_changes": ["..."], + "work_context": ["..."], + "work_scope": ["..."], + "approach": ["..."], + "outcome": ["..."], + "impact": ["..."], + "code_change_highlights": ["..."], + "decisions": ["..."], + "implementation_notes": ["..."], + "verification": ["..."], + "risks": ["..."], + "next_steps": ["..."], + "support_needed": ["..."], + "status": "Implementation", + "purpose": "Feature", + "task_group": "working-diary-notion", + "parent_index": null, + "depends_on_indices": [], + "categories": ["feature"], + "project": "", + "user_prompts": ["..."], + "files_modified": ["..."], + "files_created": ["..."], + "commands_run": ["..."], + "commit_hashes": ["..."], + "errors": ["..."] + } + ] +} +``` + +5. Run `working-diary notion push --input .diary-notion-<8-random>.json`. +6. If `working-diary` is not available, run `claude-diary notion push --input .diary-notion-<8-random>.json`. +7. Report pushed/skipped/failed tasks from the CLI output. + +If there are no task-worthy changes, explain that and do not call the CLI. diff --git a/skills/diary-notion/agents/openai.yaml b/skills/diary-notion/agents/openai.yaml new file mode 100644 index 0000000..885d284 --- /dev/null +++ b/skills/diary-notion/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Diary Notion" + short_description: "Push session tasks to Notion" + default_prompt: "Use $diary-notion to push this session to Notion." + +policy: + allow_implicit_invocation: true diff --git a/skills/diary/SKILL.md b/skills/diary/SKILL.md new file mode 100644 index 0000000..2792a4d --- /dev/null +++ b/skills/diary/SKILL.md @@ -0,0 +1,34 @@ +--- +name: diary +description: Write the current Codex work session to the manual working diary. Use when the user invokes $diary or asks Codex to record the current work session as a Markdown diary entry, including prompts, files, commands, summaries, errors, categories, and git metadata. +--- + +# Diary + +Record the current Codex session as a manual Markdown work diary entry. + +## Workflow + +1. Summarize the current conversation and tool activity into one diary entry. +2. Use the current cwd folder name as `project`. +3. Create `.diary-<8-random>.json` in cwd with this shape: + +```json +{ + "session_id": "", + "project": "", + "user_prompts": ["..."], + "files_modified": ["..."], + "files_created": ["..."], + "commands_run": ["..."], + "summary_hints": ["..."], + "errors": ["..."], + "categories": ["feature"] +} +``` + +4. Run `working-diary write --input .diary-<8-random>.json`. +5. If `working-diary` is not available, run `claude-diary write --input .diary-<8-random>.json`. +6. Report the CLI result. The CLI removes the temp file after a successful write. + +Only include content visible in the current conversation or tool history. Do not invent work. diff --git a/skills/diary/agents/openai.yaml b/skills/diary/agents/openai.yaml new file mode 100644 index 0000000..a010c4f --- /dev/null +++ b/skills/diary/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Diary" + short_description: "Record a Codex session to Markdown" + default_prompt: "Use $diary to record this session in my working diary." + +policy: + allow_implicit_invocation: true diff --git a/src/claude_diary/cli/__init__.py b/src/claude_diary/cli/__init__.py index 1ca4374..76f1ce8 100644 --- a/src/claude_diary/cli/__init__.py +++ b/src/claude_diary/cli/__init__.py @@ -120,10 +120,15 @@ def main(): p_install = sub.add_parser("install", help="Register claude-diary hook in Claude Code") p_install.add_argument("--force", action="store_true", help="Overwrite slash command files (preserves user-modified ones)") - sub.add_parser("uninstall", help="Remove claude-diary hook from Claude Code") + p_install.add_argument("--codex", action="store_true", + help="Also install Codex skills under ~/.codex/skills") + p_uninstall = sub.add_parser("uninstall", help="Remove claude-diary hook from Claude Code") + p_uninstall.add_argument("--codex", action="store_true", + help="Also remove Codex skills installed by claude-diary") # write (manual diary — for /diary slash command) - sub.add_parser("write", help="Write current session diary to ///") + p_write = sub.add_parser("write", help="Write current session diary to ///") + p_write.add_argument("--input", help="JSON input file for agent-authored diary entries") # notion (hierarchical Notion DB integration — for /diary-notion slash command) p_notion = sub.add_parser("notion", help="Notion hierarchical DB integration") diff --git a/src/claude_diary/cli/notion_init.py b/src/claude_diary/cli/notion_init.py index af6e155..257d112 100644 --- a/src/claude_diary/cli/notion_init.py +++ b/src/claude_diary/cli/notion_init.py @@ -65,7 +65,7 @@ def cmd_notion_init(args): print("\nSaved to: %s" % get_config_path()) print(" exporters.notion_hierarchical.api_token = secret_***") print(" exporters.notion_hierarchical.root_page_id = %s" % page_id) - print("\nTry it: type /diary-notion in any Claude Code session.") + print("\nTry it: type /diary-notion in Claude Code or $diary-notion in Codex.") def parse_page_id(input_str): diff --git a/src/claude_diary/cli/notion_push.py b/src/claude_diary/cli/notion_push.py index 1e5252a..04edc73 100644 --- a/src/claude_diary/cli/notion_push.py +++ b/src/claude_diary/cli/notion_push.py @@ -1,7 +1,7 @@ -"""`claude-diary notion-push` — push tasks JSON to the Notion hierarchical DB. +"""`claude-diary notion push` — push tasks JSON to the Notion hierarchical DB. -Driven by the `/diary-notion` slash command: - 1. Claude writes `.diary-notion-.json` in cwd +Driven by the `/diary-notion` slash command or `$diary-notion` Codex skill: + 1. The agent writes `.diary-notion-.json` in cwd 2. This CLI reads it, resolves git info, and pushes each task as a DB row 3. On success the temp file is deleted; on partial failure it is preserved so the user can re-push with `--force` @@ -54,12 +54,16 @@ def cmd_notion_push(args): session_id = data.get("session_id") or _fallback_session_id() tasks = data.get("tasks") or [] if not tasks: - print("[claude-diary notion-push] No tasks to push.") + print("[claude-diary notion push] No tasks to push.") _cleanup(input_path) return cwd = os.getcwd() - lang = config.get("lang", "ko") + # Hierarchical Notion diary pages are developer work records for this + # workflow: titles and narrative body sections are Korean by policy. Raw + # artifacts such as file paths, commands, branches, commits, and enum + # values remain unchanged. + lang = "ko" tz_offset = config.get("timezone_offset", 9) local_tz = timezone(timedelta(hours=tz_offset)) today = datetime.now(local_tz) @@ -76,9 +80,9 @@ def cmd_notion_push(args): try: db_id = exporter.ensure_database(year) archived = exporter.archive_rows_for_session(db_id, session_id) - print("[claude-diary notion-push] --force: archived %d existing row(s)" % archived) + print("[claude-diary notion push] --force: archived %d existing row(s)" % archived) except NotionAuthError as e: - print("[claude-diary notion-push] Auth error: %s" % e, file=sys.stderr) + print("[claude-diary notion push] Auth error: %s" % e, file=sys.stderr) print(" Check: claude-diary config or run `claude-diary notion init`", file=sys.stderr) sys.exit(1) @@ -110,10 +114,11 @@ def cmd_notion_push(args): except Exception as e: results["failed"].append((idx, title, str(e))) - # Pass 2: wire up Depends On relations now that all row_ids are known. + # Pass 2: wire up relation fields now that all row_ids are known. relation_failures = _wire_depends_on(exporter, tasks, row_ids) + relation_failures += _wire_parent_tasks(exporter, tasks, row_ids) for idx, title, reason in relation_failures: - results["failed"].append((idx, title, "depends_on: %s" % reason)) + results["failed"].append((idx, title, "relation: %s" % reason)) exporter.save_cache() @@ -145,10 +150,52 @@ def _wire_depends_on(exporter, tasks, row_ids): try: exporter.update_row_relation(my_row, target_rows) except Exception as e: - failures.append((idx, task.get("title") or "(untitled)", str(e))) + failures.append((idx, task.get("title") or "(untitled)", "depends_on: %s" % e)) return failures +def _wire_parent_tasks(exporter, tasks, row_ids): + """Pass 2: set Parent Task relations for task containment. + + `parent_index` is a single zero-based task index in the same push. It is + deliberately separate from `depends_on_indices`: parent means containment, + depends-on means execution order. + """ + failures = [] + for idx, task in enumerate(tasks): + parent_idx = _get_parent_index(task) + if parent_idx is None: + continue + my_row = row_ids.get(idx) + parent_row = row_ids.get(parent_idx) + if not my_row or not parent_row: + continue + try: + exporter.update_row_parent(my_row, parent_row) + except Exception as e: + failures.append((idx, task.get("title") or "(untitled)", "parent_task: %s" % e)) + return failures + + +def _get_parent_index(task): + """Return the optional parent task index, accepting the old/new field name.""" + if "parent_index" in task: + value = task.get("parent_index") + else: + value = task.get("parent_task_index") + if value is None or value == "": + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value >= 0 else None + try: + parsed = int(str(value).strip()) + except (TypeError, ValueError): + return None + return parsed if parsed >= 0 else None + + def _resolve_credentials(config): """Resolve token and root_page_id from env vars first, then config.""" notion_cfg = (config.get("exporters") or {}).get("notion_hierarchical") or {} @@ -165,13 +212,13 @@ def _resolve_credentials(config): def _read_json(path): if not path or not os.path.exists(path): - print("[claude-diary notion-push] Input file not found: %s" % path, file=sys.stderr) + print("[claude-diary notion push] Input file not found: %s" % path, file=sys.stderr) return None try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, IOError) as e: - print("[claude-diary notion-push] Failed to read JSON: %s" % e, file=sys.stderr) + print("[claude-diary notion push] Failed to read JSON: %s" % e, file=sys.stderr) return None @@ -222,12 +269,48 @@ def _gather_git_info(cwd, commit_hashes): VALID_STATUSES = {"Discussion", "Design", "Implementation", "Testing", "Deployed"} +VALID_PURPOSES = { + "Feature", + "Bugfix", + "Refactor", + "Docs", + "Test", + "Infra", + "Planning", + "Research", + "Review", + "Release", + "Support", + "Maintenance", + "General", +} + +PURPOSE_ALIASES = { + "bug": "Bugfix", + "bugfix": "Bugfix", + "doc": "Docs", + "docs": "Docs", + "documentation": "Docs", + "feature": "Feature", + "infra": "Infra", + "infrastructure": "Infra", + "maintenance": "Maintenance", + "planning": "Planning", + "refactor": "Refactor", + "release": "Release", + "research": "Research", + "review": "Review", + "support": "Support", + "test": "Test", + "testing": "Test", +} + def _build_properties(task, date_str, branch, git_info, session_id, task_index): """Build Notion DB row properties from task data. - Note: `Depends On` relation is NOT set here — it's wired up in pass 2 - via update_row_relation() once all row IDs are known. + Note: relation properties are NOT set here — `Depends On` and + `Parent Task` are wired up in pass 2 once all row IDs are known. """ title = task.get("title") or "(untitled)" project = (task.get("project") or "unknown").strip() or "unknown" @@ -244,6 +327,7 @@ def _build_properties(task, date_str, branch, git_info, session_id, task_index): "Name": {"title": [{"text": {"content": title[:2000]}}]}, "Date": {"date": {"start": date_str}}, "Project": {"select": {"name": _safe_select(project)}}, + "Purpose": {"select": {"name": _normalize_purpose(task.get("purpose"))}}, "Categories": { "multi_select": [{"name": _safe_select(c)} for c in categories[:10]] }, @@ -272,11 +356,21 @@ def _safe_select(name): return (name or "").replace(",", "-")[:100] or "unknown" +def _normalize_purpose(value): + """Normalize task purpose to a stable Notion select value.""" + if not value: + return "General" + raw = str(value).strip() + if raw in VALID_PURPOSES: + return raw + return PURPOSE_ALIASES.get(raw.lower(), "General") + + def _print_report(results, input_path): pushed = len(results["pushed"]) skipped = len(results["skipped"]) failed = len(results["failed"]) - print("[claude-diary notion-push] Pushed %d, skipped %d, failed %d" % + print("[claude-diary notion push] Pushed %d, skipped %d, failed %d" % (pushed, skipped, failed)) for _, title in results["pushed"]: print(" + %s" % title) @@ -287,7 +381,7 @@ def _print_report(results, input_path): if failed > 0: print() print("Failed tasks preserved in: %s" % input_path) - print("Retry: claude-diary notion-push --input %s --force" % input_path) + print("Retry: claude-diary notion push --input %s --force" % input_path) def _cleanup(input_path): @@ -300,7 +394,7 @@ def _cleanup(input_path): def _print_setup_hint(): - print("[claude-diary notion-push] Notion hierarchical exporter not configured.", + print("[claude-diary notion push] Notion hierarchical exporter not configured.", file=sys.stderr) print(" Run: claude-diary notion init", file=sys.stderr) print(" Or set CLAUDE_DIARY_NOTION_TOKEN and CLAUDE_DIARY_NOTION_ROOT_PAGE_ID", diff --git a/src/claude_diary/cli/setup.py b/src/claude_diary/cli/setup.py index c68f10f..6191352 100644 --- a/src/claude_diary/cli/setup.py +++ b/src/claude_diary/cli/setup.py @@ -48,14 +48,40 @@ - 큰 commit("fix: 5건 개선" 같은) 한 번에 묶지 말고 의미별로 분리 - 짧은 follow-up("ㅇㅇ", "맞아") 은 직전 task에 흡수 - **commit이 0개인 의논 세션도 정상** — 의미 단위로 task N개 생성 + - 독립 상태/근거/코드 변경/검증 결과가 있거나 다른 작업을 막는 일만 row로 만들 것 + - 단순 확인 항목, 긴 SQL/JS/메모/참고 링크는 별도 row가 아니라 본문 근거로 남길 것 + - `parent_index`는 포함 관계, `depends_on_indices`는 선행 관계에만 사용할 것 3. **각 task별 추출** + - 언어 정책: + - `title`, `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `risks`, `next_steps`, `support_needed` 같은 설명형 필드는 반드시 한국어로 작성 + - `status`, `purpose` enum 값은 지정된 영어 값을 그대로 사용 + - 파일 경로, 명령어, branch, commit hash, 코드 식별자, 함수/클래스명은 원문 그대로 유지 + - `user_prompts`는 사용자가 말한 원문을 증거로 보존 - `title`: 30~50자 명사구. 시제/주어/prefix/마침표 없음 - ✅ "Notion DB 컬럼 스키마 결정", "git_info.py 리팩토링" - ❌ "오늘 DB 의논했다", "[설계] DB 컬럼" - `body_intro`: 1~3문장, 200~500자, 평어체, 결과 중심 - transcript에 없는 내용 추가 금지 (추측 X) - markdown 강조(`**굵게**`, `` `코드` ` ` ) 사용 OK + - Notion 작업 DB 기록처럼 작성. 상단은 짧게, 구조는 DB relation으로, 원자료는 본문 부록에 접어두는 기준 + - `summary_hints`: 작업 결과/의미 요약 최대 3개. 단순 파일 나열이 아니라 무엇이 달라졌는지 기록 + - `key_changes`: 개발자가 이 일지만 봐도 흐름을 이해할 수 있는 주요 변경사항 최대 3개 + - `work_context`: 왜 이 작업을 시작했는지 0~1개 + - `work_scope`: 무엇을 바꿨는지 0~1개 + - `approach`: 어떻게 해결했는지 0~1개 + - `outcome`: 결과가 무엇인지 0~1개 + - `impact`: 사용자/운영/제품/개발 품질 영향 0~3개 + - `code_change_highlights`: 실제 코드 변화 중 중요한 것만 0~3개 + - 파일/함수/명령 단위 + 동작상 의미를 함께 기록 + - full diff, 단순 포맷팅, import 정리, 문구 수정, fixture 보정은 제외 + - 동작/스키마/CLI/사용자 흐름/검증 범위가 바뀐 코드는 포함 + - `decisions`: 사용자가 결정했거나 구현 중 확정한 선택지/트레이드오프 0~3개 + - `implementation_notes`: 코드 변경 요약에 넣기 애매한 제약/호환성/마이그레이션 메모 0~4개 + - `verification`: 실행한 테스트, 검증 결과, 검증하지 못한 이유 0~3개 + - `risks`: 주의사항, 남은 리스크, 운영/사용 시 헷갈릴 수 있는 점 0~2개 + - `next_steps`: 남은 작업이나 후속 단계 0~2개 + - `support_needed`: 필요한 결정/지원이 있으면 0~1개 - `status`: 5단계 중 하나 — `Discussion` / `Design` / `Implementation` / `Testing` / `Deployed` - 한 task에 여러 단계 섞이면 **가장 진행된 단계로** (Testing 통과까지 했으면 Testing) - 결정만 했으면 Design, 코드 작성까지 했으면 Implementation, 테스트까지 했으면 Testing, @@ -63,9 +89,15 @@ - `task_group`: 며칠/여러 세션에 걸치는 큰 작업 단위 식별자 (예: `diary-notion-impl`, `auth-refactor`) - 같은 큰 작업의 task들끼리 같은 그룹명 사용 → Notion에서 group view로 묶임 - 이전 작업의 연속이면 같은 그룹명, 새 작업이면 새 그룹명 (snake-case/kebab-case 권장) + - `purpose`: 목적별 1차 분류. 아래 영어 enum 중 하나만 사용 + - `Feature` / `Bugfix` / `Refactor` / `Docs` / `Test` / `Infra` + - `Planning` / `Research` / `Review` / `Release` / `Support` / `Maintenance` / `General` + - 불확실하면 `General` + - `parent_index`: 이 task가 다른 task의 하위 작업이면 부모 task의 **같은 push 내 인덱스**. 최상위 task면 `null` + - 포함 관계에만 사용. 예: "상품 목록 포커싱"의 parent는 "로컬 테스트 진행" - `depends_on_indices`: 이 task가 선행을 의존하는 다른 task의 **같은 push 내 인덱스 배열** (예: `[0, 1]`) - 같은 JSON 안의 tasks 배열 순서 (0-base) 기준 - - 없으면 빈 배열 `[]` + - 선행 순서에만 사용. 없으면 빈 배열 `[]` - `categories`: 1~3개 (design/refactor/bugfix/test/docs/infra/discussion 등 자유 라벨) - `project`: 현재 cwd의 폴더명 - `user_prompts`, `files_modified`, `files_created`, `commands_run`, `errors` @@ -85,8 +117,24 @@ { "title": "...", "body_intro": "...", + "summary_hints": ["..."], + "key_changes": ["..."], + "work_context": ["..."], + "work_scope": ["..."], + "approach": ["..."], + "outcome": ["..."], + "impact": ["..."], + "code_change_highlights": ["..."], + "decisions": ["..."], + "implementation_notes": ["..."], + "verification": ["..."], + "risks": ["..."], + "next_steps": ["..."], + "support_needed": ["..."], "status": "Implementation", "task_group": "diary-notion-impl", + "purpose": "Feature", + "parent_index": null, "depends_on_indices": [0, 1], "categories": ["..."], "project": "", @@ -111,6 +159,143 @@ """ +CODEX_DIARY_SKILL = """\ +--- +name: diary +description: Write the current Codex work session to the manual working diary. Use when the user invokes $diary or asks Codex to record the current work session as a Markdown diary entry, including prompts, files, commands, summaries, errors, categories, and git metadata. +--- + +# Diary + +Record the current Codex session as a manual Markdown work diary entry. + +## Workflow + +1. Summarize the current conversation and tool activity into one diary entry. +2. Use the current cwd folder name as `project`. +3. Create `.diary-<8-random>.json` in cwd with this shape: + +```json +{ + "session_id": "", + "project": "", + "user_prompts": ["..."], + "files_modified": ["..."], + "files_created": ["..."], + "commands_run": ["..."], + "summary_hints": ["..."], + "errors": ["..."], + "categories": ["feature"] +} +``` + +4. Run `working-diary write --input .diary-<8-random>.json`. +5. If `working-diary` is not available, run `claude-diary write --input .diary-<8-random>.json`. +6. Report the CLI result. The CLI removes the temp file after a successful write. + +Only include content visible in the current conversation or tool history. Do not invent work. +""" + + +CODEX_DIARY_NOTION_SKILL = """\ +--- +name: diary-notion +description: Push the current Codex work session to the hierarchical Notion working diary DB. Use when the user invokes $diary-notion or asks Codex to record the current session in Notion by project, purpose, task group, branch, status, categories, files, commands, commits, and dependencies. +--- + +# Diary Notion + +Split the current Codex session into task-sized entries and push them to Notion. + +## Workflow + +1. Review the current conversation, tool calls, git branch, and relevant git commits. +2. Split work into task-sized database rows. Branch changes are hard task boundaries; within a branch, split by semantic work unit. + - Create a row for work that has its own status, evidence, code/test output, or can block another task + - Keep tiny check items, raw notes, long SQL/JS snippets, and reference links inside the page body evidence instead of making them separate rows + - Use `parent_index` for containment hierarchy and `depends_on_indices` for prerequisite order; do not mix the two +3. For each task, produce: + - Language policy: + - Write `title`, `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `risks`, `next_steps`, and `support_needed` in Korean + - Keep `status` and `purpose` as the exact English enum values below + - Preserve file paths, commands, branches, commit hashes, code identifiers, function names, and class names as written + - Preserve `user_prompts` in the user's original wording as evidence + - `title`: concise Korean noun phrase, no prefix or period + - `body_intro`: 1-3 factual Korean sentences based only on observed work + - Write it like a Notion task database record: compact top summary, structured relations in DB properties, and raw evidence hidden in the page body appendix + - `summary_hints`: up to 3 outcome-focused bullets that explain what changed and why it matters + - `key_changes`: up to 3 major behavior/schema/workflow changes a developer can understand without opening the diff + - `work_context`: 0-1 bullet explaining why this work started + - `work_scope`: 0-1 bullet explaining what changed + - `approach`: 0-1 bullet explaining how it was solved + - `outcome`: 0-1 bullet explaining the resulting state + - `impact`: 0-3 user, operations, product, or engineering-quality impacts + - `code_change_highlights`: 0-3 important code changes only + - Include file/function/command scope plus runtime or developer-facing meaning + - Exclude full diffs, formatting-only edits, import cleanup, wording-only edits, and fixture-only noise + - Include changes to behavior, schema, CLI flow, user workflow, or verification scope + - `decisions`: 0-3 decisions or tradeoffs made by the user or settled during implementation + - `implementation_notes`: 0-4 constraints, compatibility notes, migrations, or details that do not fit code highlights + - `verification`: 0-3 tests/checks run, results, or explicit reasons checks were not run + - `risks`: 0-2 cautions, remaining risks, or usage/operation notes + - `next_steps`: 0-2 remaining follow-ups + - `support_needed`: 0-1 decisions or support needed from others + - `status`: `Discussion`, `Design`, `Implementation`, `Testing`, or `Deployed` + - `purpose`: `Feature`, `Bugfix`, `Refactor`, `Docs`, `Test`, `Infra`, `Planning`, `Research`, `Review`, `Release`, `Support`, `Maintenance`, or `General` + - `task_group`: stable kebab-case/snake-case group for multi-session work + - `parent_index`: zero-based index of the parent task in this push, or `null`; use it for "part of" hierarchy + - `depends_on_indices`: zero-based indices in this push, or `[]` + - Use this only when the current task cannot proceed before another task is done + - `categories`, `project`, `user_prompts`, `files_modified`, `files_created`, `commands_run`, `commit_hashes`, `errors` +4. Create `.diary-notion-<8-random>.json` in cwd: + +```json +{ + "session_id": "", + "tasks": [ + { + "title": "...", + "body_intro": "...", + "summary_hints": ["..."], + "key_changes": ["..."], + "work_context": ["..."], + "work_scope": ["..."], + "approach": ["..."], + "outcome": ["..."], + "impact": ["..."], + "code_change_highlights": ["..."], + "decisions": ["..."], + "implementation_notes": ["..."], + "verification": ["..."], + "risks": ["..."], + "next_steps": ["..."], + "support_needed": ["..."], + "status": "Implementation", + "purpose": "Feature", + "task_group": "working-diary-notion", + "parent_index": null, + "depends_on_indices": [], + "categories": ["feature"], + "project": "", + "user_prompts": ["..."], + "files_modified": ["..."], + "files_created": ["..."], + "commands_run": ["..."], + "commit_hashes": ["..."], + "errors": ["..."] + } + ] +} +``` + +5. Run `working-diary notion push --input .diary-notion-<8-random>.json`. +6. If `working-diary` is not available, run `claude-diary notion push --input .diary-notion-<8-random>.json`. +7. Report pushed/skipped/failed tasks from the CLI output. + +If there are no task-worthy changes, explain that and do not call the CLI. +""" + + SLASH_COMMANDS = { # filename: (file content, marker substring used to detect "ours") "diary.md": (DIARY_SLASH_COMMAND, "claude-diary write"), @@ -118,12 +303,24 @@ } +CODEX_SKILLS = { + "diary": (CODEX_DIARY_SKILL, "working-diary write --input"), + "diary-notion": (CODEX_DIARY_NOTION_SKILL, "working-diary notion push"), +} + + def _get_slash_command_path(filename="diary.md"): """Return path to ~/.claude/commands/.""" home = os.path.expanduser("~") return os.path.join(home, ".claude", "commands", filename) +def _get_codex_skill_path(skill_name): + """Return path to ~/.codex/skills//SKILL.md.""" + home = os.path.expanduser("~") + return os.path.join(home, ".codex", "skills", skill_name, "SKILL.md") + + def _get_claude_settings_path(): """Return path to ~/.claude/settings.json.""" home = os.path.expanduser("~") @@ -188,19 +385,25 @@ def cmd_install(args): _save_claude_settings(settings_path, settings) hook_status = "installed" - force = bool(getattr(args, "force", False)) + force = getattr(args, "force", False) is True + install_codex = getattr(args, "codex", False) is True # Slash command install runs unconditionally — fixes upgrade path for # users who installed before a given slash command was a feature. slash_statuses = _install_all_slash_commands(force=force) + codex_statuses = _install_all_codex_skills(force=force) if install_codex else {} print("claude-diary install:") print(" Hook: %s (%s)" % (HOOK_COMMAND, hook_status)) print(" Settings: %s" % settings_path) for filename, (path, status) in slash_statuses.items(): print(" Slash command %s: %s (%s)" % (filename, path, status)) + for skill_name, (path, status) in codex_statuses.items(): + print(" Codex skill $%s: %s (%s)" % (skill_name, path, status)) print() print("Stop Hook auto-writes a diary entry on session exit.") print("Type /diary to write a manual entry, or /diary-notion to push to Notion.") + if install_codex: + print("In Codex, use $diary or $diary-notion.") def _install_all_slash_commands(force=False): @@ -212,6 +415,15 @@ def _install_all_slash_commands(force=False): return results +def _install_all_codex_skills(force=False): + """Install Codex skills in ~/.codex/skills. Returns {skill: (path, status)}.""" + results = {} + for skill_name, (content, marker) in CODEX_SKILLS.items(): + path = _get_codex_skill_path(skill_name) + results[skill_name] = (path, _install_slash_command(path, content, marker, force)) + return results + + def _install_slash_command(path, content, marker=None, force=False): """Create or refresh the slash command file. @@ -266,8 +478,18 @@ def _uninstall_all_slash_commands(): return results +def _uninstall_all_codex_skills(): + """Uninstall Codex skills that still contain our marker.""" + results = {} + for skill_name, (_content, marker) in CODEX_SKILLS.items(): + path = _get_codex_skill_path(skill_name) + results[skill_name] = (path, _uninstall_slash_command(path, marker)) + return results + + def cmd_uninstall(args): """Remove claude-diary Stop hook + slash commands.""" + uninstall_codex = getattr(args, "codex", False) is True settings_path = _get_claude_settings_path() settings = _load_claude_settings(settings_path) @@ -278,6 +500,11 @@ def cmd_uninstall(args): for filename, (path, status) in slash_statuses.items(): if status != "not present": print(" Slash command %s: %s (%s)" % (filename, path, status)) + if uninstall_codex: + codex_statuses = _uninstall_all_codex_skills() + for skill_name, (path, status) in codex_statuses.items(): + if status != "not present": + print(" Codex skill $%s: %s (%s)" % (skill_name, path, status)) return # Remove diary hooks @@ -299,8 +526,11 @@ def cmd_uninstall(args): _save_claude_settings(settings_path, settings) slash_statuses = _uninstall_all_slash_commands() + codex_statuses = _uninstall_all_codex_skills() if uninstall_codex else {} print("claude-diary hook uninstalled.") print(" Settings: %s" % settings_path) for filename, (path, status) in slash_statuses.items(): print(" Slash command %s: %s (%s)" % (filename, path, status)) + for skill_name, (path, status) in codex_statuses.items(): + print(" Codex skill $%s: %s (%s)" % (skill_name, path, status)) diff --git a/src/claude_diary/cli/write.py b/src/claude_diary/cli/write.py index b4a3295..0c75af2 100644 --- a/src/claude_diary/cli/write.py +++ b/src/claude_diary/cli/write.py @@ -1,12 +1,14 @@ """Manual diary write — on-demand structured diary generation. -Triggered by `claude-diary write` (typically via `/diary` slash command). +Triggered by `claude-diary write` (typically via `/diary` or `$diary`). Auto-detects current session's transcript and writes to: ///.md Same date + project → append. Otherwise → create. +Codex skills can pass an agent-authored JSON payload with `--input`. """ +import json import os import re import sys @@ -120,6 +122,117 @@ def _append_or_create(target_path, date_str, entry_text, lang): f.write(entry_text) +def _read_input_json(path): + if not path or not os.path.exists(path): + print("[claude-diary write] Input file not found: %s" % path, file=sys.stderr) + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + print("[claude-diary write] Failed to read JSON: %s" % e, file=sys.stderr) + return None + + +def _has_diary_content(entry_data): + return bool( + entry_data.get("user_prompts") or entry_data.get("files_modified") or + entry_data.get("files_created") or entry_data.get("commands_run") or + entry_data.get("summary_hints") + ) + + +def _as_text_list(value): + """Normalize agent-authored JSON fields to list[str].""" + if value is None: + return [] + if isinstance(value, list): + return [str(v) for v in value if v is not None and str(v).strip()] + if isinstance(value, tuple): + return [str(v) for v in value if v is not None and str(v).strip()] + text = str(value).strip() + return [text] if text else [] + + +def _entry_data_from_input(data, date_str, time_str, cwd, project): + """Build EntryData from an agent-authored JSON payload.""" + return { + "session_id": data.get("session_id") or "manual", + "date": date_str, + "time": time_str, + "project": _safe_project_name(data.get("project") or project), + "cwd": data.get("cwd") or cwd, + "user_prompts": _as_text_list(data.get("user_prompts")), + "files_created": _as_text_list(data.get("files_created")), + "files_modified": _as_text_list(data.get("files_modified")), + "commands_run": _as_text_list(data.get("commands_run")), + "summary_hints": _as_text_list(data.get("summary_hints") or data.get("summary")), + "errors_encountered": _as_text_list(data.get("errors_encountered") or data.get("errors")), + "categories": _as_text_list(data.get("categories")), + "git_info": data.get("git_info"), + "code_stats": data.get("code_stats"), + "secrets_masked": 0, + } + + +def _enrich_entry_data(entry_data, config, enrichment, session_start=None): + cwd = entry_data.get("cwd") or os.getcwd() + + if enrichment.get("git_info", True) and not entry_data.get("git_info"): + try: + git_info = collect_git_info(cwd, session_start) + if git_info: + entry_data["git_info"] = git_info + if enrichment.get("code_stats", True) and not entry_data.get("code_stats"): + entry_data["code_stats"] = git_info.get("diff_stat") + except Exception as e: + logger.warning("Git enrichment failed: %s", e) + + if enrichment.get("auto_category", True) and not entry_data.get("categories"): + try: + entry_data["categories"] = categorize( + entry_data, config.get("custom_categories") or None + ) + except Exception as e: + logger.warning("Auto-categorization failed: %s", e) + + try: + scan_entry_data( + entry_data, + config.get("security", {}).get("additional_secret_patterns") or None, + ) + except Exception as e: + logger.warning("Secret scan failed: %s", e) + + +def _write_manual_entry(entry_data, manual_dir, lang): + entry_text = format_entry(entry_data, lang) + date_str = entry_data["date"] + project = _safe_project_name(entry_data.get("project") or "unknown") + target = Path(manual_dir) / date_str / project / ("%s.md" % date_str) + + existed = target.exists() + try: + _append_or_create(target, date_str, entry_text, lang) + except OSError as e: + print("[claude-diary write] Failed to write diary file.", file=sys.stderr) + print(" target: %s" % target, file=sys.stderr) + print(" error: %s" % e, file=sys.stderr) + print(" hint: check CLAUDE_DIARY_MANUAL_DIR or `claude-diary config --set manual_diary_dir=`", file=sys.stderr) + sys.exit(1) + + return "appended to" if existed else "created", target + + +def _cleanup(input_path): + if not input_path: + return + try: + os.remove(input_path) + except OSError: + pass + + def cmd_write(args): """Generate manual diary entry for the current session.""" config = load_config() @@ -135,6 +248,26 @@ def cmd_write(args): cwd = os.getcwd() project = _safe_project_name(_extract_project_name(cwd)) + local_tz = timezone(timedelta(hours=tz_offset)) + now = datetime.now(local_tz) + date_str = now.strftime("%Y-%m-%d") + time_str = now.strftime("%H:%M:%S") + + input_path = getattr(args, "input", None) + if input_path: + data = _read_input_json(input_path) + if data is None: + sys.exit(1) + entry_data = _entry_data_from_input(data, date_str, time_str, cwd, project) + if not _has_diary_content(entry_data): + print("[claude-diary write] Input has no diary-worthy content.", file=sys.stderr) + sys.exit(1) + _enrich_entry_data(entry_data, config, enrichment, data.get("session_start")) + action, target = _write_manual_entry(entry_data, manual_dir, lang) + _cleanup(input_path) + print("[claude-diary write] %s %s" % (action, target)) + return + transcript_path = _find_latest_transcript(cwd) if not transcript_path: encoded = _encode_cwd(os.path.abspath(cwd)) @@ -146,21 +279,8 @@ def cmd_write(args): print(" not by claude-diary.", file=sys.stderr) sys.exit(1) - local_tz = timezone(timedelta(hours=tz_offset)) - now = datetime.now(local_tz) - date_str = now.strftime("%Y-%m-%d") - time_str = now.strftime("%H:%M:%S") - parsed = parse_transcript(transcript_path, max_lines=config.get("max_transcript_lines")) - has_content = bool( - parsed.get("user_prompts") or parsed.get("files_modified") or - parsed.get("files_created") or parsed.get("commands_run") - ) - if not has_content: - print("[claude-diary write] Transcript has no diary-worthy content yet.", file=sys.stderr) - sys.exit(1) - entry_data = { "session_id": "manual", "date": date_str, @@ -179,44 +299,10 @@ def cmd_write(args): "secrets_masked": 0, } - if enrichment.get("git_info", True): - try: - git_info = collect_git_info(cwd, parsed.get("session_start")) - if git_info: - entry_data["git_info"] = git_info - if enrichment.get("code_stats", True): - entry_data["code_stats"] = git_info.get("diff_stat") - except Exception as e: - logger.warning("Git enrichment failed: %s", e) - - if enrichment.get("auto_category", True): - try: - entry_data["categories"] = categorize( - entry_data, config.get("custom_categories") or None - ) - except Exception as e: - logger.warning("Auto-categorization failed: %s", e) - - try: - scan_entry_data( - entry_data, - config.get("security", {}).get("additional_secret_patterns") or None, - ) - except Exception as e: - logger.warning("Secret scan failed: %s", e) - - entry_text = format_entry(entry_data, lang) - target = Path(manual_dir) / date_str / project / ("%s.md" % date_str) - - existed = target.exists() - try: - _append_or_create(target, date_str, entry_text, lang) - except OSError as e: - print("[claude-diary write] Failed to write diary file.", file=sys.stderr) - print(" target: %s" % target, file=sys.stderr) - print(" error: %s" % e, file=sys.stderr) - print(" hint: check CLAUDE_DIARY_MANUAL_DIR or `claude-diary config --set manual_diary_dir=`", file=sys.stderr) + if not _has_diary_content(entry_data): + print("[claude-diary write] Transcript has no diary-worthy content yet.", file=sys.stderr) sys.exit(1) - action = "appended to" if existed else "created" + _enrich_entry_data(entry_data, config, enrichment, parsed.get("session_start")) + action, target = _write_manual_entry(entry_data, manual_dir, lang) print("[claude-diary write] %s %s" % (action, target)) diff --git a/src/claude_diary/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py index 84789bf..a23a765 100644 --- a/src/claude_diary/exporters/notion_hierarchical.py +++ b/src/claude_diary/exporters/notion_hierarchical.py @@ -3,13 +3,13 @@ Structure (on Notion): [Root page (user-specified)] ├─ 2026 (page, auto-created) - │ └─ Entries (database, auto-created, 10 columns) + │ └─ Entries (database, auto-created, shared schema) │ ├─ row 1 │ └─ row 2 ├─ 2027 └─ ... -Used by `/diary-notion` slash command via `claude-diary notion-push`. +Used by `/diary-notion` and `$diary-notion` via `claude-diary notion push`. Separate from the flat-mode NotionExporter (Stop Hook auto-push). Error handling policy: @@ -32,6 +32,7 @@ NOTION_API_BASE = "https://api.notion.com/v1" MAX_RETRIES = 3 RICH_TEXT_LIMIT = 2000 +SCHEMA_VERSION = "v4" class NotionAuthError(Exception): @@ -50,7 +51,7 @@ class NotionHierarchicalExporter: """Pushes tasks to the year/database/row hierarchy. Unlike BaseExporter subclasses, this is invoked directly by the - `notion-push` CLI with a list of tasks, not a single entry_data. + `notion push` CLI with a list of tasks, not a single entry_data. """ def __init__(self, config): @@ -208,7 +209,8 @@ def _create_year_page(self, year): def ensure_database(self, year): """Get Entries database ID for a year, creating if missing. - Also ensures the schema extensions (Status, Task Group, Depends On) + Also ensures the schema extensions (Purpose, Status, Task Group, Depends On, + Parent Task) are present — needed for older DBs created before those columns were part of the design. Tracked via cache so we only patch once. """ @@ -231,35 +233,49 @@ def ensure_database(self, year): return db_id def _ensure_db_schema_extensions(self, db_id): - """Add Status/Task Group/Depends On if not yet recorded in cache. + """Add current schema extensions if not yet recorded in cache. Patching the same property twice is harmless (Notion treats existing properties as no-ops), but we still gate by a cache flag to skip the API call on the happy path. """ schema_v = self._cache.setdefault("schema_v", {}) - if schema_v.get(db_id) == "v2": + current = schema_v.get(db_id) + if current == SCHEMA_VERSION: + return + if current == "v3": + self._request("PATCH", "/databases/%s" % db_id, { + "properties": { + "Parent Task": _self_relation(db_id), + } + }) + schema_v[db_id] = SCHEMA_VERSION + return + if current == "v2": + self._request("PATCH", "/databases/%s" % db_id, { + "properties": { + "Purpose": {"select": {}}, + "Parent Task": _self_relation(db_id), + } + }) + schema_v[db_id] = SCHEMA_VERSION return self._request("PATCH", "/databases/%s" % db_id, { "properties": { + "Purpose": {"select": {}}, "Status": {"select": {}}, "Task Group": {"select": {}}, - "Depends On": { - "relation": { - "database_id": db_id, - "type": "single_property", - "single_property": {}, - } - }, + "Depends On": _self_relation(db_id), + "Parent Task": _self_relation(db_id), } }) - schema_v[db_id] = "v2" + schema_v[db_id] = SCHEMA_VERSION def _create_database(self, parent_page_id): """Create the Entries inline database with the base schema. - Status, Task Group, and the self-relation Depends On are added - afterwards by `_ensure_db_schema_extensions` — keeping them out + Purpose, Status, Task Group, and self-relations are added afterwards + by `_ensure_db_schema_extensions` — keeping them out of this call means new and pre-existing DBs go through the same upgrade path. """ @@ -364,6 +380,16 @@ def update_row_relation(self, row_id, depends_on_row_ids): } }) + def update_row_parent(self, row_id, parent_row_id): + """PATCH a row to set its `Parent Task` containment relation.""" + self._request("PATCH", "/pages/%s" % row_id, { + "properties": { + "Parent Task": { + "relation": [{"id": parent_row_id}] + } + } + }) + def _short_error(resp): """Extract a one-line error description from a Notion error response.""" @@ -372,3 +398,14 @@ def _short_error(resp): return data.get("message") or data.get("code") or resp.text[:200] except Exception: return resp.text[:200] + + +def _self_relation(db_id): + """Return a self-relation schema object for the current database API version.""" + return { + "relation": { + "database_id": db_id, + "type": "single_property", + "single_property": {}, + } + } diff --git a/src/claude_diary/formatter.py b/src/claude_diary/formatter.py index 7ae0546..494c27e 100644 --- a/src/claude_diary/formatter.py +++ b/src/claude_diary/formatter.py @@ -119,12 +119,13 @@ def build_notion_blocks(task, git_info=None, lang="ko"): """Build Notion API block list for a task page body. Body layout: - [body_intro paragraph] - ## 사용자 요청 - prompts - ## 수정/생성 파일 - files (modified + created) - ## 실행한 명령 - commands (trivial filtered out) - ## Git 변경사항 - branch, commits, lines - ## 발생한 에러 - errors (only if any) + [body_intro callout] + ## 요약 - up to 3 outcome-focused callouts + ## 작업 한눈에 - context/scope/approach/outcome callouts + ## 영향 - impact callouts + ## 검증 및 상태 - checked verification items + risk callouts + ## 다음 액션 - unchecked next-step/support items + ## 부록 - collapsed developer/raw evidence toggles Skipped sections render no heading (page doesn't get noisy with empty heads). Long strings are truncated to RICH_TEXT_LIMIT (Notion rich_text caps at 2000). @@ -134,62 +135,192 @@ def build_notion_blocks(task, git_info=None, lang="ko"): intro = (task.get("body_intro") or "").strip() if intro: - blocks.append(_paragraph(intro)) + blocks.append(_callout(intro, "📌")) + + _add_callout_section( + blocks, + L("brief_summary"), + task.get("summary_hints") or task.get("summary"), + 3, + "✅", + ) + + _add_snapshot_section(blocks, task, L) + + _add_callout_section(blocks, L("impact"), task.get("impact"), 3, "📈") + + _add_validation_section(blocks, task, L) + _add_next_actions_section(blocks, task, L) + + appendix = _build_appendix_blocks(task, git_info, L) + if appendix: + blocks.append(_heading(L("appendix"))) + blocks.extend(appendix) + + return blocks + + +def _add_snapshot_section(blocks, task, L): + pairs = [ + (L("context"), task.get("work_context") or task.get("context"), "🧭"), + (L("scope"), task.get("work_scope") or task.get("scope"), "🧩"), + (L("approach"), task.get("approach"), "🛠️"), + (L("outcome"), task.get("outcome"), "🎯"), + ] + section_blocks = [] + for label, value, icon in pairs: + texts = _limited_texts(value, 1) + if texts: + section_blocks.append(_callout("%s: %s" % (label, texts[0]), icon)) + if section_blocks: + blocks.append(_heading(L("work_snapshot"))) + blocks.extend(section_blocks) + + +def _add_validation_section(blocks, task, L): + section_blocks = [] + for v in _limited_texts(task.get("verification"), 3): + section_blocks.append(_to_do(v, checked=True)) + for r in _limited_texts(task.get("risks") or task.get("cautions"), 2): + section_blocks.append(_callout("%s: %s" % (L("risks"), r), "⚠️")) + if section_blocks: + blocks.append(_heading(L("validation_status"))) + blocks.extend(section_blocks) + + +def _add_next_actions_section(blocks, task, L): + section_blocks = [] + for n in _limited_texts(task.get("next_steps"), 2): + section_blocks.append(_to_do("%s: %s" % (L("next_steps"), n), checked=False)) + for s in _limited_texts(task.get("support_needed"), 1): + section_blocks.append(_to_do("%s: %s" % (L("support_needed"), s), checked=False)) + if section_blocks: + blocks.append(_heading(L("next_actions"))) + blocks.extend(section_blocks) + + +def _build_appendix_blocks(task, git_info, L): + blocks = [] + developer = _build_developer_evidence_items(task, git_info, L) + raw = _build_raw_evidence_items(task, L) + + if developer: + blocks.append(_toggle(L("developer_evidence"), [_bullet(item) for item in developer])) + if raw: + blocks.append(_toggle(L("raw_evidence"), [_bullet(item) for item in raw])) + return blocks + + +def _build_developer_evidence_items(task, git_info, L): + evidence = [] + + for c in _limited_texts(task.get("key_changes"), 3): + evidence.append("%s: %s" % (L("key_changes"), c)) + + for c in _limited_texts(task.get("code_change_highlights") or task.get("code_changes"), 3): + evidence.append("%s: %s" % (L("code_change_highlights"), c)) + + for d in _limited_texts(task.get("decisions"), 2): + evidence.append("%s: %s" % (L("decisions"), d)) + + for n in _limited_texts(task.get("implementation_notes"), 2): + evidence.append("%s: %s" % (L("implementation_notes"), n)) + + modified = _as_text_list(task.get("files_modified")) + created = _as_text_list(task.get("files_created")) + if modified: + evidence.append("%s: %s" % (L("files_modified"), _join_limited(modified, 6))) + if created: + evidence.append("%s: %s" % (L("files_created"), _join_limited(created, 6))) + + commands = _significant_commands(task.get("commands_run")) + for c in commands[:3]: + evidence.append("%s: %s" % (L("commands"), _truncate(c, 500))) + + if git_info: + branch = git_info.get("branch", "") + commits = git_info.get("commits", []) + stat = git_info.get("diff_stat") or {} + if branch: + evidence.append("%s: %s" % (L("branch"), branch)) + for c in commits[:3]: + short_hash = c.get("short_hash") or (c.get("hash") or "")[:7] + msg = c.get("message", "") + evidence.append("%s: %s" % (L("commit"), _truncate("%s %s" % (short_hash, msg), 500))) + if stat.get("added") or stat.get("deleted"): + evidence.append("%s: +%d / -%d (%d files)" % ( + L("code_stats"), stat.get("added", 0), stat.get("deleted", 0), stat.get("files", 0) + )) + + errors = _as_text_list(task.get("errors") or task.get("errors_encountered")) + for e in errors[:2]: + evidence.append("%s: %s" % (L("issues"), _truncate(e, 500))) + + return evidence - prompts = task.get("user_prompts") or [] - if prompts: - blocks.append(_heading(L("task_requests"))) - for p in prompts[:5]: - short = _truncate(p.replace("\n", " ").strip(), 500) - if short: - blocks.append(_bullet(short)) - - modified = task.get("files_modified") or [] - created = task.get("files_created") or [] - if modified or created: - blocks.append(_heading("%s / %s" % (L("files_modified"), L("files_created")))) - for f in modified[:15]: - blocks.append(_bullet(_truncate(f, 500))) - for f in created[:15]: - blocks.append(_bullet(_truncate("%s (+)" % f, 500))) - commands = task.get("commands_run") or [] +def _build_raw_evidence_items(task, L): + raw = [] + for p in _limited_texts(task.get("user_prompts"), 3): + raw.append("%s: %s" % (L("task_requests"), p)) + return raw + + +def _significant_commands(commands): trivial = {"ls", "pwd", "cat", "echo", "cd", "which", "type", "clear"} sig = [] - for c in commands: + for c in _as_text_list(commands): first = c.strip().split()[0] if c.strip() else "" if first and first not in trivial: sig.append(c) - sig = sig[:10] - if sig: - blocks.append(_heading(L("commands"))) - for c in sig: - blocks.append(_bullet(_truncate(c, 500))) + return sig - if git_info: - branch = git_info.get("branch", "") - commits = git_info.get("commits", []) - stat = git_info.get("diff_stat") or {} - if branch or commits or stat.get("added") or stat.get("deleted"): - blocks.append(_heading(L("git"))) - if branch: - blocks.append(_paragraph("%s: %s" % (L("branch"), branch))) - for c in commits[:10]: - short_hash = c.get("short_hash") or (c.get("hash") or "")[:7] - msg = c.get("message", "") - blocks.append(_bullet(_truncate("%s %s" % (short_hash, msg), 500))) - if stat.get("added") or stat.get("deleted"): - blocks.append(_paragraph("+%d / -%d (%d files)" % ( - stat.get("added", 0), stat.get("deleted", 0), stat.get("files", 0) - ))) - - errors = task.get("errors") or [] - if errors: - blocks.append(_heading(L("issues"))) - for e in errors[:5]: - blocks.append(_bullet(_truncate(e, 500))) - return blocks +def _labeled_texts(label, items, max_items, limit=500): + return ["%s: %s" % (label, item) for item in _limited_texts(items, max_items, limit)] + + +def _limited_texts(items, max_items, limit=500): + texts = [_truncate(item.replace("\n", " ").strip(), limit) for item in _as_text_list(items)] + return [item for item in texts if item][:max_items] + + +def _join_limited(items, max_items, limit=500): + texts = _limited_texts(items, max_items, limit) + extra = len(_as_text_list(items)) - len(texts) + suffix = " (+%d more)" % extra if extra > 0 else "" + return _truncate(", ".join(texts) + suffix, limit) + + +def _add_bullet_section(blocks, title, items, max_items, limit=500): + """Append a heading and bullet list if items contain usable text.""" + texts = _limited_texts(items, max_items, limit) + if not texts: + return + blocks.append(_heading(title)) + for item in texts: + blocks.append(_bullet(item)) + + +def _add_callout_section(blocks, title, items, max_items, icon, limit=500): + """Append a heading and compact callouts if items contain usable text.""" + texts = _limited_texts(items, max_items, limit) + if not texts: + return + blocks.append(_heading(title)) + for item in texts: + blocks.append(_callout(item, icon)) + + +def _as_text_list(value): + """Normalize scalar or list-like task fields into strings.""" + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, (list, tuple, set)): + return [str(item) for item in value if item is not None] + return [str(value)] def _paragraph(text): @@ -216,6 +347,39 @@ def _bullet(text): } +def _callout(text, emoji): + return { + "object": "block", + "type": "callout", + "callout": { + "rich_text": _rich_text(text), + "icon": {"type": "emoji", "emoji": emoji}, + }, + } + + +def _to_do(text, checked=False): + return { + "object": "block", + "type": "to_do", + "to_do": { + "rich_text": _rich_text(text), + "checked": checked, + }, + } + + +def _toggle(text, children): + return { + "object": "block", + "type": "toggle", + "toggle": { + "rich_text": _rich_text(text), + "children": children, + }, + } + + def _rich_text(text): return [{"type": "text", "text": {"content": _truncate(text, 2000)}}] diff --git a/src/claude_diary/i18n.py b/src/claude_diary/i18n.py index 7599653..06d3a23 100644 --- a/src/claude_diary/i18n.py +++ b/src/claude_diary/i18n.py @@ -10,6 +10,33 @@ "files_modified": "수정된 파일", "commands": "주요 명령어", "summary": "작업 요약", + "brief_summary": "요약", + "work_overview": "작업 개요", + "work_details": "작업 상세", + "work_snapshot": "작업 한눈에", + "context": "배경", + "scope": "범위", + "approach": "접근", + "outcome": "결과", + "impact": "영향", + "key_changes": "주요 변경사항", + "code_change_highlights": "주요 코드 변경", + "decisions": "결정 / 트레이드오프", + "implementation_notes": "구현 메모", + "decisions_and_notes": "결정 및 구현 메모", + "verification": "검증", + "validation": "검증", + "validation_status": "검증 및 상태", + "verification_and_followups": "검증 및 후속 조치", + "risks": "주의사항 / 리스크", + "next_steps": "다음 단계", + "next_actions": "다음 액션", + "support_needed": "필요한 지원 / 결정", + "risks_next_steps": "리스크 및 다음 단계", + "evidence": "원자료", + "appendix": "부록", + "developer_evidence": "개발 근거", + "raw_evidence": "원문 요청", "issues": "발생한 이슈", "session_id": "세션 ID", "categories": "카테고리", @@ -51,6 +78,33 @@ "files_modified": "Files Modified", "commands": "Key Commands", "summary": "Work Summary", + "brief_summary": "Summary", + "work_overview": "Work Overview", + "work_details": "Work Details", + "work_snapshot": "Work Snapshot", + "context": "Context", + "scope": "Scope", + "approach": "Approach", + "outcome": "Outcome", + "impact": "Impact", + "key_changes": "Key Changes", + "code_change_highlights": "Code Change Highlights", + "decisions": "Decisions / Trade-offs", + "implementation_notes": "Implementation Notes", + "decisions_and_notes": "Decisions and Implementation Notes", + "verification": "Verification", + "validation": "Validation", + "validation_status": "Validation and Status", + "verification_and_followups": "Verification and Follow-ups", + "risks": "Notes / Risks", + "next_steps": "Next Steps", + "next_actions": "Next Actions", + "support_needed": "Support / Decision Needed", + "risks_next_steps": "Risks and Next Steps", + "evidence": "Evidence", + "appendix": "Appendix", + "developer_evidence": "Developer Evidence", + "raw_evidence": "Original Requests", "issues": "Issues Encountered", "session_id": "Session ID", "categories": "Categories", diff --git a/tests/test_cli.py b/tests/test_cli.py index ef86cd4..29d3853 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -894,10 +894,11 @@ class TestCmdInit: @patch("claude_diary.cli.ensure_diary_dir") @patch("claude_diary.cli.load_config") def test_init_basic(self, mock_config, mock_ensure, mock_save, mock_path, - base_config, capsys): + base_config, tmp_path, capsys): mock_config.return_value = base_config # settings.json doesn't exist - with patch("os.path.exists", return_value=False): + with patch("os.path.exists", return_value=False), \ + patch("os.path.expanduser", return_value=str(tmp_path)): args = Namespace(team_repo=None) cmd_init(args) captured = capsys.readouterr() diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py new file mode 100644 index 0000000..c047195 --- /dev/null +++ b/tests/test_codex_plugin.py @@ -0,0 +1,48 @@ +"""Tests for Codex plugin and skill packaging artifacts.""" + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_codex_plugin_manifest_points_to_skills(): + manifest_path = ROOT / ".codex-plugin" / "plugin.json" + data = json.loads(manifest_path.read_text(encoding="utf-8")) + + assert data["name"] == "working-diary" + assert data["skills"] == "./skills/" + assert data["interface"]["displayName"] == "Working Diary" + assert "hooks" not in data + + +def test_codex_skills_exist_and_cover_diary_workflows(): + diary = ROOT / "skills" / "diary" / "SKILL.md" + notion = ROOT / "skills" / "diary-notion" / "SKILL.md" + + diary_text = diary.read_text(encoding="utf-8") + notion_text = notion.read_text(encoding="utf-8") + + assert "working-diary write --input" in diary_text + assert "claude-diary write --input" in diary_text + assert "working-diary notion push" in notion_text + assert '"purpose": "Feature"' in notion_text + assert '"summary_hints": ["..."]' in notion_text + assert '"key_changes": ["..."]' in notion_text + assert '"work_context": ["..."]' in notion_text + assert '"impact": ["..."]' in notion_text + assert '"code_change_highlights": ["..."]' in notion_text + assert '"implementation_notes": ["..."]' in notion_text + assert '"verification": ["..."]' in notion_text + assert '"risks": ["..."]' in notion_text + assert '"support_needed": ["..."]' in notion_text + assert '"parent_index": null' in notion_text + assert "Exclude full diffs" in notion_text + assert "in Korean" in notion_text + assert "Notion task database record" in notion_text + + +def test_codex_skill_metadata_exists(): + assert (ROOT / "skills" / "diary" / "agents" / "openai.yaml").exists() + assert (ROOT / "skills" / "diary-notion" / "agents" / "openai.yaml").exists() diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 175cd74..411afcf 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -100,10 +100,19 @@ def test_invalid_date(self): def _block_text(block): """Helper: extract content string from a Notion block.""" btype = block["type"] + if "rich_text" not in block[btype]: + return "" rt = block[btype]["rich_text"] return "".join(r["text"]["content"] for r in rt) +def _flatten_blocks(blocks): + for block in blocks: + yield block + children = block[block["type"]].get("children", []) + yield from _flatten_blocks(children) + + class TestBuildNotionBlocks: def test_empty_task_returns_empty(self): blocks = build_notion_blocks({}) @@ -111,36 +120,100 @@ def test_empty_task_returns_empty(self): def test_body_intro_becomes_first_paragraph(self): blocks = build_notion_blocks({"body_intro": "한 줄 요약."}) - assert blocks[0]["type"] == "paragraph" + assert blocks[0]["type"] == "callout" assert _block_text(blocks[0]) == "한 줄 요약." def test_user_prompts_section(self): blocks = build_notion_blocks({"user_prompts": ["첫 요청", "두 번째 요청"]}) - # heading_2 + 2 bullets + flat = list(_flatten_blocks(blocks)) assert blocks[0]["type"] == "heading_2" - assert "작업 요청" in _block_text(blocks[0]) - assert blocks[1]["type"] == "bulleted_list_item" - assert _block_text(blocks[1]) == "첫 요청" - assert _block_text(blocks[2]) == "두 번째 요청" + assert "부록" in _block_text(blocks[0]) + assert blocks[1]["type"] == "toggle" + assert _block_text(blocks[1]) == "원문 요청" + assert _block_text(flat[2]) == "작업 요청: 첫 요청" + assert _block_text(flat[3]) == "작업 요청: 두 번째 요청" + + def test_rich_notion_body_sections(self): + blocks = build_notion_blocks({ + "body_intro": "Implemented richer Notion body content.", + "summary_hints": ["Added structured body sections"], + "key_changes": ["Notion entries now read as developer work records"], + "work_context": "The previous body looked like a raw log.", + "work_scope": "Reorganize the Notion body for daily reporting.", + "approach": "Group high-level information above raw evidence.", + "outcome": "The body reads as a brief.", + "impact": ["Managers can scan the result quickly"], + "code_change_highlights": [ + "`formatter.py`: renders high-signal code change bullets without full diff", + ], + "decisions": ["Keep Notion view automation separate"], + "implementation_notes": ["Render optional fields only when present"], + "verification": "pytest passed", + "risks": ["Existing sessions need refreshed installed commands"], + "next_steps": ["Install refreshed Codex skills"], + "user_prompts": ["Make the Notion body less sparse"], + }, lang="en") + flat = list(_flatten_blocks(blocks)) + texts = [_block_text(b) for b in flat] + headings = [_block_text(b) for b in blocks if b["type"] == "heading_2"] + + assert texts[0] == "Implemented richer Notion body content." + assert "Summary" in headings + assert "Work Snapshot" in headings + assert "Impact" in headings + assert "Validation and Status" in headings + assert "Next Actions" in headings + assert "Appendix" in headings + assert "Added structured body sections" in texts + assert "Key Changes: Notion entries now read as developer work records" in texts + assert "Context: The previous body looked like a raw log." in texts + assert "Impact" in headings + assert "Managers can scan the result quickly" in texts + assert "Code Change Highlights: `formatter.py`: renders high-signal code change bullets without full diff" in texts + assert "pytest passed" in texts + assert "Notes / Risks: Existing sessions need refreshed installed commands" in texts + assert "Next Steps: Install refreshed Codex skills" in texts + assert "Developer Evidence" in texts + assert "Original Requests" in texts + assert texts.index("Summary") < texts.index("Appendix") + + def test_rich_notion_body_limits_summary_bullets(self): + blocks = build_notion_blocks({ + "summary_hints": ["summary-%d" % i for i in range(9)], + }, lang="en") + texts = [_block_text(b) for b in blocks] + + assert "summary-2" in texts + assert "summary-3" not in texts + + def test_code_changes_alias_limits_to_three_bullets(self): + blocks = build_notion_blocks({ + "code_changes": ["change-%d" % i for i in range(8)], + }, lang="en") + texts = [_block_text(b) for b in _flatten_blocks(blocks)] + + assert "Appendix" in texts + assert "Code Change Highlights: change-2" in texts + assert "Code Change Highlights: change-3" not in texts def test_files_section_marks_created_with_plus(self): blocks = build_notion_blocks({ "files_modified": ["src/main.py"], "files_created": ["src/new.py"], }) - texts = [_block_text(b) for b in blocks if b["type"] == "bulleted_list_item"] - assert "src/main.py" in texts - assert any("src/new.py" in t and "(+)" in t for t in texts) + texts = [_block_text(b) for b in _flatten_blocks(blocks) if b["type"] == "bulleted_list_item"] + assert "수정된 파일: src/main.py" in texts + assert "생성된 파일: src/new.py" in texts def test_trivial_commands_filtered_out(self): blocks = build_notion_blocks({ "commands_run": ["ls", "pwd", "npm test", "git status"], }) - bullets = [_block_text(b) for b in blocks if b["type"] == "bulleted_list_item"] - assert "npm test" in bullets - assert "git status" in bullets - assert "ls" not in bullets - assert "pwd" not in bullets + bullets = [_block_text(b) for b in _flatten_blocks(blocks) if b["type"] == "bulleted_list_item"] + assert "주요 명령어: npm test" in bullets + assert "주요 명령어: git status" in bullets + assert all("ls" not in b for b in bullets) + assert all("pwd" not in b for b in bullets) def test_git_section_with_branch_and_commits(self): git_info = { @@ -151,7 +224,7 @@ def test_git_section_with_branch_and_commits(self): "diff_stat": {"added": 42, "deleted": 8, "files": 3}, } blocks = build_notion_blocks({"title": "t"}, git_info=git_info) - text = "\n".join(_block_text(b) for b in blocks) + text = "\n".join(_block_text(b) for b in _flatten_blocks(blocks)) assert "feat/diary-notion" in text assert "abc1234" in text assert "feat: x" in text @@ -165,15 +238,54 @@ def test_errors_section_only_when_present(self): assert "이슈" not in _block_text(b) blocks_dirty = build_notion_blocks({"errors": ["ImportError: requests"]}) - headings = [_block_text(b) for b in blocks_dirty if b["type"] == "heading_2"] - assert any("이슈" in h for h in headings) + text = "\n".join(_block_text(b) for b in _flatten_blocks(blocks_dirty)) + assert "부록" in text + assert "발생한 이슈: ImportError: requests" in text + + def test_errors_encountered_fallback(self): + blocks = build_notion_blocks({"errors_encountered": ["ImportError: requests"]}, lang="en") + text = "\n".join(_block_text(b) for b in _flatten_blocks(blocks)) + assert "Issues Encountered" in text + assert "ImportError: requests" in text def test_long_text_truncated_below_2000(self): long_intro = "x" * 5000 blocks = build_notion_blocks({"body_intro": long_intro}) - content = blocks[0]["paragraph"]["rich_text"][0]["text"]["content"] + content = blocks[0]["callout"]["rich_text"][0]["text"]["content"] assert len(content) <= 2000 def test_english_labels(self): blocks = build_notion_blocks({"user_prompts": ["hello"]}, lang="en") - assert _block_text(blocks[0]) == "Task Requests" + assert _block_text(blocks[0]) == "Appendix" + + def test_compact_body_stays_under_notion_children_limit(self): + task = { + "body_intro": "x", + "summary_hints": ["summary-%d" % i for i in range(7)], + "key_changes": ["change-%d" % i for i in range(6)], + "work_context": ["context-%d" % i for i in range(3)], + "work_scope": ["scope-%d" % i for i in range(3)], + "approach": ["approach-%d" % i for i in range(3)], + "outcome": ["outcome-%d" % i for i in range(3)], + "impact": ["impact-%d" % i for i in range(6)], + "code_change_highlights": ["code-%d" % i for i in range(6)], + "decisions": ["decision-%d" % i for i in range(5)], + "implementation_notes": ["note-%d" % i for i in range(8)], + "verification": ["verify-%d" % i for i in range(5)], + "risks": ["risk-%d" % i for i in range(5)], + "next_steps": ["next-%d" % i for i in range(5)], + "support_needed": ["support-%d" % i for i in range(4)], + "user_prompts": ["prompt-%d" % i for i in range(5)], + "files_modified": ["modified-%d.py" % i for i in range(15)], + "files_created": ["created-%d.py" % i for i in range(15)], + "commands_run": ["npm test %d" % i for i in range(10)], + "errors": ["error-%d" % i for i in range(5)], + } + git_info = { + "branch": "main", + "commits": [{"hash": "abc1234%d" % i, "message": "msg-%d" % i} for i in range(10)], + "diff_stat": {"added": 42, "deleted": 8, "files": 3}, + } + blocks = build_notion_blocks(task, git_info=git_info) + + assert len(blocks) <= 95 diff --git a/tests/test_notion_hierarchical.py b/tests/test_notion_hierarchical.py index 70fa82d..135626f 100644 --- a/tests/test_notion_hierarchical.py +++ b/tests/test_notion_hierarchical.py @@ -215,7 +215,7 @@ def test_creates_database_with_full_schema(self, tmp_path): # Three calls: # 1. GET /blocks/year_page (existence check from ensure_year_page cache hit) # 2. POST /databases (create base schema) - # 3. PATCH /databases/{id} (Status + Task Group + Depends On extension) + # 3. PATCH /databases/{id} (Purpose + Status + Task Group + relation extensions) mock_req.request.side_effect = [ _make_response(200, {"id": "year_page"}), _make_response(200, {"id": "db_xyz"}), @@ -236,25 +236,29 @@ def test_creates_database_with_full_schema(self, tmp_path): "Categories", "Files", "Commits", "Lines", "Session ID", "Task Index"]: assert col in props - # Status/Task Group/Depends On NOT in create body — added by schema extension PATCH + # Purpose/Status/Task Group/relation columns NOT in create body — added by schema extension PATCH + assert "Purpose" not in props assert "Status" not in props assert "Task Group" not in props assert "Depends On" not in props + assert "Parent Task" not in props - # Third call: PATCH to add Status + Task Group + Depends On + # Third call: PATCH to add Purpose + Status + Task Group + relation columns patch_call = mock_req.request.call_args_list[2] assert patch_call.args[0] == "PATCH" assert patch_call.args[1].endswith("/databases/db_xyz") patch_body = patch_call.kwargs["json"] - for col in ["Status", "Task Group", "Depends On"]: + for col in ["Purpose", "Status", "Task Group", "Depends On", "Parent Task"]: assert col in patch_body["properties"] relation = patch_body["properties"]["Depends On"]["relation"] assert relation["database_id"] == "db_xyz" # self-reference + parent_relation = patch_body["properties"]["Parent Task"]["relation"] + assert parent_relation["database_id"] == "db_xyz" # Cache flag recorded so future calls skip the PATCH - assert exp._cache["schema_v"]["db_xyz"] == "v2" + assert exp._cache["schema_v"]["db_xyz"] == "v4" def test_existing_database_gets_schema_extension(self, tmp_path): - """Cache-hit database (older schema) gets Status/Task Group/Depends On via PATCH.""" + """Cache-hit database (older schema) gets current schema columns via PATCH.""" exp = _make_exporter() with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): exp.load_cache() @@ -273,17 +277,17 @@ def test_existing_database_gets_schema_extension(self, tmp_path): # Second call: PATCH for schema extension on the existing DB patch_call = mock_req.request.call_args_list[1] assert patch_call.args[0] == "PATCH" - for col in ["Status", "Task Group", "Depends On"]: + for col in ["Purpose", "Status", "Task Group", "Depends On", "Parent Task"]: assert col in patch_call.kwargs["json"]["properties"] - assert exp._cache["schema_v"]["old_db"] == "v2" + assert exp._cache["schema_v"]["old_db"] == "v4" def test_schema_extension_skipped_when_already_flagged(self, tmp_path): - """Once schema_v records the DB at v2, no further PATCH is sent.""" + """Once schema_v records the DB at v4, no further PATCH is sent.""" exp = _make_exporter() with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): exp.load_cache() exp._cache["databases"]["2026"] = "db_known" - exp._cache["schema_v"]["db_known"] = "v2" + exp._cache["schema_v"]["db_known"] = "v4" mock_req = MagicMock() mock_req.request.return_value = _make_response(200, {"id": "db_known"}) @@ -295,6 +299,51 @@ def test_schema_extension_skipped_when_already_flagged(self, tmp_path): assert mock_req.request.call_count == 1 assert mock_req.request.call_args.args[0] == "GET" + def test_v3_database_gets_parent_task_upgrade(self, tmp_path): + """A DB already marked v3 still gets the v4 Parent Task schema patch.""" + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["databases"]["2026"] = "db_v3" + exp._cache["schema_v"]["db_v3"] = "v3" + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, {"id": "db_v3"}), + _make_response(200, {"id": "db_v3"}), + ] + with _patch_requests(mock_req): + db_id = exp.ensure_database(2026) + + assert db_id == "db_v3" + patch_body = mock_req.request.call_args_list[1].kwargs["json"] + assert list(patch_body["properties"].keys()) == ["Parent Task"] + relation = patch_body["properties"]["Parent Task"]["relation"] + assert relation["database_id"] == "db_v3" + assert exp._cache["schema_v"]["db_v3"] == "v4" + + def test_v2_database_gets_purpose_and_parent_task_upgrade(self, tmp_path): + """A DB already marked v2 still gets Purpose and Parent Task schema patches.""" + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["databases"]["2026"] = "db_v2" + exp._cache["schema_v"]["db_v2"] = "v2" + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, {"id": "db_v2"}), + _make_response(200, {"id": "db_v2"}), + ] + with _patch_requests(mock_req): + db_id = exp.ensure_database(2026) + + assert db_id == "db_v2" + patch_body = mock_req.request.call_args_list[1].kwargs["json"] + assert "Purpose" in patch_body["properties"] + assert "Parent Task" in patch_body["properties"] + assert exp._cache["schema_v"]["db_v2"] == "v4" + class TestFindExistingRow: def test_returns_none_when_no_match(self, tmp_path): @@ -388,6 +437,35 @@ def test_posts_to_pages_endpoint(self, tmp_path): assert body["children"] == body_blocks +class TestUpdateRelations: + def test_update_row_relation_sets_depends_on(self): + exp = _make_exporter() + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "row_b"}) + + with _patch_requests(mock_req): + exp.update_row_relation("row_b", ["row_a", "row_c"]) + + body = mock_req.request.call_args.kwargs["json"] + assert body["properties"]["Depends On"]["relation"] == [ + {"id": "row_a"}, + {"id": "row_c"}, + ] + + def test_update_row_parent_sets_parent_task(self): + exp = _make_exporter() + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "row_child"}) + + with _patch_requests(mock_req): + exp.update_row_parent("row_child", "row_parent") + + body = mock_req.request.call_args.kwargs["json"] + assert body["properties"]["Parent Task"]["relation"] == [ + {"id": "row_parent"}, + ] + + class TestRequestsMissing: def test_friendly_error_when_requests_not_installed(self): exp = _make_exporter() diff --git a/tests/test_notion_push.py b/tests/test_notion_push.py index ce5ace0..0cb6747 100644 --- a/tests/test_notion_push.py +++ b/tests/test_notion_push.py @@ -12,6 +12,7 @@ _safe_select, _build_properties, _gather_git_info, + _normalize_purpose, _read_json, ) from claude_diary.exporters.notion_hierarchical import ( @@ -71,6 +72,20 @@ def test_empty_returns_unknown(self): assert _safe_select(None) == "unknown" +class TestNormalizePurpose: + def test_valid_purpose_passes_through(self): + assert _normalize_purpose("Feature") == "Feature" + + def test_aliases_normalized(self): + assert _normalize_purpose("documentation") == "Docs" + assert _normalize_purpose("testing") == "Test" + + def test_missing_or_unknown_returns_general(self): + assert _normalize_purpose("") == "General" + assert _normalize_purpose(None) == "General" + assert _normalize_purpose("unexpected") == "General" + + class TestBuildProperties: def _base_task(self): return { @@ -88,10 +103,12 @@ def test_all_columns_present(self): "commits": [{"hash": "abc", "short_hash": "abc", "message": "m"}], "diff_stat": {"added": 10, "deleted": 5, "files": 2}, } + task["purpose"] = "Feature" props = _build_properties(task, "2026-05-26", "feat/x", git_info, "sess1", 0) assert props["Name"]["title"][0]["text"]["content"] == "DB 결정" assert props["Date"]["date"]["start"] == "2026-05-26" assert props["Project"]["select"]["name"] == "diary" + assert props["Purpose"]["select"]["name"] == "Feature" assert props["Branch"]["select"]["name"] == "feat/x" assert props["Categories"]["multi_select"][0]["name"] == "design" assert props["Files"]["number"] == 2 @@ -120,6 +137,11 @@ def test_no_commits_zeros(self): assert props["Commits"]["number"] == 0 assert props["Lines"]["number"] == 0 + def test_missing_purpose_defaults_to_general(self): + task = self._base_task() + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert props["Purpose"]["select"]["name"] == "General" + def test_valid_status_included(self): task = dict(self._base_task(), status="Implementation") props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) @@ -145,6 +167,7 @@ def test_depends_on_not_in_properties(self): task = dict(self._base_task(), depends_on_indices=[0, 1]) props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) assert "Depends On" not in props + assert "Parent Task" not in props class TestDependsOnWiring: @@ -224,6 +247,52 @@ def test_wire_depends_on_skips_when_self_failed(self): _wire_depends_on(mock_exp, tasks, row_ids) mock_exp.update_row_relation.assert_not_called() + def test_wire_parent_tasks_calls_update_for_children(self, tmp_path): + input_path = tmp_path / "in.json" + self._write_json(str(input_path), { + "session_id": "s1", + "tasks": [ + {"title": "A"}, + {"title": "B", "parent_index": 0}, + {"title": "C", "parent_task_index": 1}, + ], + }) + config = { + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + + mock_exp = MagicMock() + mock_exp.ensure_database.return_value = "db_xyz" + mock_exp.find_existing_row.return_value = None + mock_exp.create_row.side_effect = ["row_a", "row_b", "row_c"] + mock_exp._cache = {"rows": {}, "years": {}, "databases": {}, "root_page_id": "p"} + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter", + return_value=mock_exp), \ + patch("claude_diary.cli.notion_push.get_head_branch", return_value="main"), \ + pytest.raises(SystemExit): + cmd_notion_push(self._make_args(str(input_path))) + + parent_calls = mock_exp.update_row_parent.call_args_list + assert len(parent_calls) == 2 + assert parent_calls[0].args == ("row_b", "row_a") + assert parent_calls[1].args == ("row_c", "row_b") + + def test_wire_parent_tasks_skips_missing_parent(self): + from claude_diary.cli.notion_push import _wire_parent_tasks + mock_exp = MagicMock() + tasks = [ + {"title": "A"}, + {"title": "B", "parent_index": 99}, + ] + row_ids = {0: "row_a", 1: "row_b"} + _wire_parent_tasks(mock_exp, tasks, row_ids) + mock_exp.update_row_parent.assert_not_called() + class TestGatherGitInfo: def test_with_commit_hashes(self): @@ -344,6 +413,38 @@ def test_successful_push_cleans_up(self, tmp_path): mock_exp.create_row.assert_called_once() assert not input_path.exists() + def test_notion_body_uses_korean_labels_even_when_config_lang_en(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "tasks": [{"title": "작업 제목", "summary_hints": ["요약"], "project": "diary"}], + }) + config = { + "lang": "en", + "exporters": { + "notion_hierarchical": {"api_token": "t", "root_page_id": "p"} + } + } + + mock_exp = MagicMock() + mock_exp.ensure_database.return_value = "db_xyz" + mock_exp.find_existing_row.return_value = None + mock_exp.create_row.return_value = "row_abc" + mock_exp._cache = {"rows": {}, "years": {}, "databases": {}, "root_page_id": "p"} + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value=config), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter", + return_value=mock_exp), \ + patch("claude_diary.cli.notion_push.get_head_branch", return_value="main"), \ + patch("claude_diary.cli.notion_push.build_notion_blocks", return_value=[]) as mock_blocks, \ + pytest.raises(SystemExit) as exc: + cmd_notion_push(_make_args(str(input_path))) + + assert exc.value.code == 0 + mock_blocks.assert_called_once() + assert mock_blocks.call_args.args[2] == "ko" + def test_skip_existing_row(self, tmp_path): input_path = tmp_path / "in.json" _write_json(str(input_path), { diff --git a/tests/test_setup.py b/tests/test_setup.py index 629458a..a91d4d1 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -6,6 +6,7 @@ from claude_diary.cli.setup import ( SLASH_COMMANDS, + CODEX_SKILLS, DIARY_SLASH_COMMAND, DIARY_NOTION_SLASH_COMMAND, cmd_install, @@ -14,6 +15,8 @@ _uninstall_slash_command, _install_all_slash_commands, _uninstall_all_slash_commands, + _install_all_codex_skills, + _uninstall_all_codex_skills, ) @@ -37,6 +40,51 @@ def test_each_entry_has_content_and_marker(self): assert marker in content, "Marker %r must be findable in %s" % (marker, filename) +class TestCodexSkillRegistry: + def test_codex_skills_registered(self): + assert "diary" in CODEX_SKILLS + assert "diary-notion" in CODEX_SKILLS + + def test_each_skill_has_content_and_marker(self): + for skill_name, entry in CODEX_SKILLS.items(): + content, marker = entry + assert content + assert marker in content, "Marker %r must be findable in %s" % (marker, skill_name) + + +class TestDiaryNotionInstructions: + def test_slash_and_codex_skill_request_rich_body_fields(self): + fields = [ + "summary_hints", + "key_changes", + "work_context", + "work_scope", + "approach", + "outcome", + "impact", + "code_change_highlights", + "decisions", + "implementation_notes", + "verification", + "risks", + "next_steps", + "support_needed", + "parent_index", + "depends_on_indices", + ] + codex_content = CODEX_SKILLS["diary-notion"][0] + + for field in fields: + assert field in DIARY_NOTION_SLASH_COMMAND + assert field in codex_content + assert "full diff" in DIARY_NOTION_SLASH_COMMAND + assert "formatting-only" in codex_content + assert "반드시 한국어로 작성" in DIARY_NOTION_SLASH_COMMAND + assert "Write `title`, `body_intro`" in codex_content + assert "in Korean" in codex_content + assert "Notion task database record" in codex_content + + class TestInstallSlashCommandSingle: def test_creates_file_when_missing(self, tmp_path): path = tmp_path / "new-command.md" @@ -146,6 +194,20 @@ def test_upgrade_path_keeps_existing_diary(self, tmp_path): assert existing_diary.read_text(encoding="utf-8") == "user-customized content" +class TestInstallAllCodexSkills: + def test_installs_codex_skills(self, tmp_path): + with _patch_home(tmp_path): + results = _install_all_codex_skills() + assert "diary" in results + assert "diary-notion" in results + diary = tmp_path / ".codex" / "skills" / "diary" / "SKILL.md" + notion = tmp_path / ".codex" / "skills" / "diary-notion" / "SKILL.md" + assert diary.exists() + assert notion.exists() + assert "working-diary write --input" in diary.read_text(encoding="utf-8") + assert "working-diary notion push" in notion.read_text(encoding="utf-8") + + class TestUninstallAll: def test_removes_only_unmodified_files(self, tmp_path): commands_dir = tmp_path / ".claude" / "commands" @@ -163,6 +225,15 @@ def test_removes_only_unmodified_files(self, tmp_path): assert not ours.exists() assert notion_modified.exists() + def test_uninstalls_codex_skills(self, tmp_path): + with _patch_home(tmp_path): + _install_all_codex_skills() + results = _uninstall_all_codex_skills() + + assert results["diary"][1] == "removed" + assert results["diary-notion"][1] == "removed" + assert not (tmp_path / ".codex" / "skills" / "diary" / "SKILL.md").exists() + class TestCmdInstall: def test_writes_hook_and_both_slashes_into_empty_settings(self, tmp_path, capsys): @@ -182,6 +253,16 @@ def test_writes_hook_and_both_slashes_into_empty_settings(self, tmp_path, capsys assert (tmp_path / ".claude" / "commands" / "diary.md").exists() assert (tmp_path / ".claude" / "commands" / "diary-notion.md").exists() + def test_install_with_codex_writes_skills(self, tmp_path): + args = MagicMock() + args.force = False + args.codex = True + with _patch_home(tmp_path): + cmd_install(args) + + assert (tmp_path / ".codex" / "skills" / "diary" / "SKILL.md").exists() + assert (tmp_path / ".codex" / "skills" / "diary-notion" / "SKILL.md").exists() + def test_idempotent_when_hook_already_present(self, tmp_path): settings_path = tmp_path / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True) @@ -228,3 +309,14 @@ def test_no_hook_still_cleans_unmodified_slashes(self, tmp_path): cmd_uninstall(MagicMock()) # Still gets cleaned up assert not (commands_dir / "diary.md").exists() + + def test_uninstall_with_codex_removes_skills(self, tmp_path): + args = MagicMock() + args.force = False + args.codex = True + with _patch_home(tmp_path): + cmd_install(args) + cmd_uninstall(args) + + assert not (tmp_path / ".codex" / "skills" / "diary" / "SKILL.md").exists() + assert not (tmp_path / ".codex" / "skills" / "diary-notion" / "SKILL.md").exists() diff --git a/tests/test_write.py b/tests/test_write.py index 103502a..f10d63e 100644 --- a/tests/test_write.py +++ b/tests/test_write.py @@ -219,3 +219,80 @@ def test_no_transcript_exits_nonzero(self, tmp_path, monkeypatch, capsys): assert exc.value.code == 1 err = capsys.readouterr().err assert "No transcript found" in err + + def test_input_json_creates_entry_without_claude_transcript(self, tmp_path, monkeypatch, capsys): + cwd = tmp_path / "codex-project" + cwd.mkdir() + input_path = cwd / ".diary-abc12345.json" + input_path.write_text(json.dumps({ + "session_id": "codex-session", + "project": "codex-project", + "user_prompts": ["record this session"], + "files_modified": ["src/app.py"], + "commands_run": ["pytest"], + "summary_hints": ["Added Codex diary input support"], + "categories": ["feature"], + }), encoding="utf-8") + + monkeypatch.setenv("CLAUDE_DIARY_MANUAL_DIR", str(tmp_path / "manual")) + monkeypatch.setenv("APPDATA", str(tmp_path / "appdata")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(cwd) + args = types.SimpleNamespace(input=str(input_path)) + + write_mod.cmd_write(args) + + out = capsys.readouterr().out + assert "created" in out + assert not input_path.exists() + files = list((tmp_path / "manual").glob("*/*/*.md")) + assert len(files) == 1 + content = files[0].read_text(encoding="utf-8") + assert "record this session" in content + assert "Added Codex diary input support" in content + + def test_input_json_without_content_exits_nonzero(self, tmp_path, monkeypatch, capsys): + cwd = tmp_path / "codex-empty" + cwd.mkdir() + input_path = cwd / ".diary-empty.json" + input_path.write_text(json.dumps({"session_id": "codex-empty"}), encoding="utf-8") + + monkeypatch.setenv("CLAUDE_DIARY_MANUAL_DIR", str(tmp_path / "manual")) + monkeypatch.setenv("APPDATA", str(tmp_path / "appdata")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(cwd) + args = types.SimpleNamespace(input=str(input_path)) + + with pytest.raises(SystemExit) as exc: + write_mod.cmd_write(args) + assert exc.value.code == 1 + assert input_path.exists() + assert "no diary-worthy content" in capsys.readouterr().err + + def test_input_json_normalizes_scalar_fields(self, tmp_path, monkeypatch, capsys): + cwd = tmp_path / "codex-scalar" + cwd.mkdir() + input_path = cwd / ".diary-scalar.json" + input_path.write_text(json.dumps({ + "session_id": "codex-scalar", + "project": "codex-scalar", + "user_prompts": "record scalar fields", + "files_modified": "src/scalar.py", + "commands_run": "pytest tests/test_write.py", + "summary": "Scalar fields were normalized", + "errors": "none", + "categories": "feature", + }), encoding="utf-8") + + monkeypatch.setenv("CLAUDE_DIARY_MANUAL_DIR", str(tmp_path / "manual")) + monkeypatch.setenv("APPDATA", str(tmp_path / "appdata")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.chdir(cwd) + + write_mod.cmd_write(types.SimpleNamespace(input=str(input_path))) + + assert not input_path.exists() + content = next((tmp_path / "manual").glob("*/*/*.md")).read_text(encoding="utf-8") + assert "record scalar fields" in content + assert "src/scalar.py" in content + assert "Scalar fields were normalized" in content From 9e6d0fbb60941346533f8af74e8bfdd591aca4ee Mon Sep 17 00:00:00 2001 From: cys Date: Mon, 1 Jun 2026 17:58:07 +0900 Subject: [PATCH 14/30] docs: add working diary os vision --- CHANGELOG.md | 1 + .../diary-notion-hierarchical.design.md | 2 + .../features/working-diary-os.vision.md | 317 ++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 docs/02-design/features/working-diary-os.vision.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a8057ab..e07d81f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **DB 자동 생성 스키마**: Name, Date, Project, Purpose, Branch, Status, Task Group, Parent Task, Categories, Files, Commits, Lines, Depends On, Session ID, Task Index - **Notion 작업 DB 관계 구조**: `Parent Task` self-relation을 추가해 포함 관계를 DB 컬럼에 기록하고, 기존 `Depends On`은 선행 관계로 유지 - **접힌 근거 중심 Notion 본문**: page body를 callout/checklist/toggle 기반으로 압축해 상단은 요약과 상태, 부록은 코드·파일·명령어·Git·원문 요청 근거로 분리 +- **Working Diary OS 비전 문서**: Structure → Views → Operations → Intelligence → Multi-project OS로 확장하는 최고모델 설계와 전날 todo 기반 `today-plan` 방향 추가 - **`claude-diary write --input `**: Codex skill이 생성한 JSON으로 수동 Markdown 일지 작성 - **`lib/notion_cache.py`**: 연도 페이지/DB/행 ID 캐시 (root_page_id 변경 시 자동 무효화) - **`lib/git_info.py` 확장**: `get_branch_for_commit`, `get_head_branch`, `get_commit_info`, `get_diff_stat_for_commits` diff --git a/docs/02-design/features/diary-notion-hierarchical.design.md b/docs/02-design/features/diary-notion-hierarchical.design.md index 40e41d3..b555cbd 100644 --- a/docs/02-design/features/diary-notion-hierarchical.design.md +++ b/docs/02-design/features/diary-notion-hierarchical.design.md @@ -6,6 +6,8 @@ > **Date**: 2026-05-26 > **Status**: Draft (설계 의논 중) +> 상위 비전: [`working-diary-os.vision.md`](working-diary-os.vision.md) + ## Executive Summary | 관점 | 내용 | diff --git a/docs/02-design/features/working-diary-os.vision.md b/docs/02-design/features/working-diary-os.vision.md new file mode 100644 index 0000000..428c2c1 --- /dev/null +++ b/docs/02-design/features/working-diary-os.vision.md @@ -0,0 +1,317 @@ +# Working Diary OS Vision + +> **Summary**: AI 코딩 세션, Git, Notion 작업 DB, 로컬 작업 메모를 연결해 기록, 구조화, 조회, 운영, 리뷰까지 지원하는 개인 업무 운영 시스템의 최종 방향 +> +> **Project**: claude-code-hooks-diary +> **Date**: 2026-06-01 +> **Status**: Vision Draft + +## 1. 목적 + +Working Diary OS는 단순 작업일지 생성기가 아니다. 최종 목표는 사용자가 여러 프로젝트와 여러 AI 세션에서 수행한 일을 자동으로 기록하고, 작업 구조와 근거를 잃지 않으며, 다음 행동과 우선순위를 판단할 수 있게 만드는 개인 업무 운영 시스템이다. + +핵심 질문은 다음 세 가지다. + +- 오늘 무엇을 했는가? +- 무엇이 막혀 있고 왜 막혀 있는가? +- 다음에 무엇을 해야 하는가? + +## 2. 최종 사용자 경험 + +사용자는 평소에는 세션 안에서 짧게 명령만 실행한다. + +```bash +$diary +$diary-notion +``` + +시스템은 세션의 대화, 도구 사용, 파일 변경, 명령어, Git 정보를 기반으로 작업 단위 row를 만든다. Notion에서는 다음과 같이 볼 수 있어야 한다. + +```text +프로젝트 A + 큰 작업 1 + 세부 작업 1 + 세부 작업 2 + 큰 작업 2 + +프로젝트 B + 막힌 작업 + 검증 대기 작업 +``` + +추가 명령은 필요할 때만 실행한다. + +```bash +working-diary notion views ensure +working-diary notion sync-status --dry-run +working-diary notion today-plan +working-diary notion review +``` + +최종적으로 사용자는 Notion DB를 열어 다음을 확인할 수 있어야 한다. + +- 작업 계층: 어떤 일이 어떤 큰 작업의 하위인지 +- 상태: 논의, 설계, 구현, 테스트, 배포 중 어디인지 +- 종속성: 무엇이 선행되어야 하는지 +- 근거: 어떤 파일, 명령어, 커밋, 테스트가 있었는지 +- 리스크: 무엇이 막혀 있고 누가 결정해야 하는지 +- 다음 액션: 다음 세션에서 무엇부터 해야 하는지 +- 오늘 계획: 전날 남긴 todo와 `next_steps`를 기준으로 무엇을 우선 처리해야 하는지 + +## 3. 핵심 원칙 + +### 3.1 기록은 자동, 판단은 보수적으로 + +세션에서 관찰된 사실은 자동으로 남긴다. 하지만 상태 변경, 우선순위, blocked 판정처럼 사용자 판단에 영향을 주는 값은 보수적으로 계산하고, 가능하면 dry-run으로 먼저 보여준다. + +### 3.2 구조는 DB, 근거는 본문 + +작업 구조는 Notion DB property로 표현한다. + +- `Project` +- `Purpose` +- `Task Group` +- `Status` +- `Parent Task` +- `Depends On` + +본문은 사람이 읽는 근거를 담당한다. + +- 요약 +- 작업 한눈에 +- 검증 및 상태 +- 다음 액션 +- 접힌 부록: 코드 변경, 파일, 명령어, Git, 원문 요청 + +### 3.3 포함 관계와 선행 관계를 분리 + +`Parent Task`와 `Depends On`은 의미가 다르다. + +```text +Parent Task = 포함 관계 +예: "상품 목록 포커싱"은 "로컬 테스트 진행"의 하위 작업 + +Depends On = 선행 관계 +예: "웹페이지 구동 확인"은 "docker 실행"이 끝나야 가능 +``` + +두 관계를 섞지 않는다. 이 원칙이 깨지면 view, 진행률, blocked 계산이 모두 부정확해진다. + +### 3.4 수동 수정은 자동화보다 우선 + +사용자가 Notion에서 직접 고친 값은 자동화가 함부로 덮어쓰지 않는다. 자동화가 값을 바꿀 때는 다음 중 하나를 만족해야 한다. + +- 명령에 `--apply`가 명시되어 있다. +- 값이 시스템 전용 컬럼이다. +- 사용자가 명시적으로 force/sync를 요청했다. + +### 3.5 작업 row는 의미 단위로만 만든다 + +row로 만들 기준: + +- 독립적으로 상태를 추적할 작업 +- 다른 작업의 선행 조건이 되는 작업 +- 파일/코드/테스트/커밋 근거가 남는 작업 +- 며칠 뒤 다시 찾아야 하는 작업 + +본문 checklist로 둘 기준: + +- 단순 확인 항목 +- 긴 SQL/JS/로그/메모 +- 참고 링크 +- 너무 작은 단계 + +## 4. 데이터 모델 + +### 4.1 현재 고정 모델 + +| 필드 | 역할 | +|------|------| +| `Name` | 작업 제목 | +| `Date` | 작업 기록일 | +| `Project` | 프로젝트 필터/그룹 | +| `Purpose` | Feature, Bugfix, Planning 등 목적 | +| `Status` | Discussion, Design, Implementation, Testing, Deployed | +| `Task Group` | 여러 세션을 묶는 큰 작업 단위 | +| `Parent Task` | 포함 관계 | +| `Depends On` | 선행 관계 | +| `Categories` | 보조 라벨 | +| `Files`, `Commits`, `Lines` | 변경 규모 | +| `Session ID`, `Task Index` | 멱등성 | + +### 4.2 확장 후보 모델 + +3차 이후 추가를 검토한다. + +| 필드 | 역할 | +|------|------| +| `Progress` | 하위 작업 기준 진행률 | +| `Blocked` | 선행 작업/리스크 기반 막힘 여부 | +| `Block Reason` | 막힘 이유 | +| `Last Reviewed` | 자동/수동 리뷰 시점 | +| `Priority` | 사용자가 승인한 우선순위 | +| `Review Notes` | 주간/일간 리뷰 결과 | + +확장 컬럼은 기존 핵심 모델을 대체하지 않고 보조한다. + +## 5. 자동화 경계 + +### 5.1 에이전트 책임 + +- 세션을 의미 단위 작업으로 나눈다. +- 제목과 설명형 본문을 한국어로 작성한다. +- 파일, 명령어, branch, commit hash, 코드 식별자는 원문을 보존한다. +- `parent_index`와 `depends_on_indices`를 구분해 작성한다. + +### 5.2 CLI 책임 + +- JSON을 검증하고 Notion row를 생성한다. +- Git 메타데이터를 수집한다. +- `Parent Task`와 `Depends On` relation을 row 생성 후 연결한다. +- Notion schema/view/status 동기화는 명령 단위로 분리한다. + +### 5.3 리뷰 엔진 책임 + +4차 이후의 책임이다. + +- 오래 방치된 작업을 찾는다. +- 반복되는 리스크를 요약한다. +- 전날/최근 N일의 미완료 todo와 `next_steps`를 수집한다. +- `Depends On`, `Blocked`, `Status`, `Task Group` 연속성을 반영해 오늘 우선순위를 제안한다. +- 다음 액션 후보를 제안한다. +- 상사 보고용 daily/weekly brief를 생성한다. + +리뷰 엔진은 기본적으로 제안만 한다. 실제 Status/Priority 변경은 별도 apply 단계가 필요하다. + +## 6. Phase Roadmap + +### Phase 1. Structure + +목표: 작업을 정확한 DB row와 relation으로 기록한다. + +완료 기준: + +- Claude `/diary-notion`과 Codex `$diary-notion`이 동일한 작업 구조를 생성한다. +- `Parent Task`와 `Depends On`이 분리된다. +- page body는 compact body와 접힌 근거로 정리된다. + +### Phase 2. Views + +목표: Notion에서 작업 관리 화면처럼 보이게 만든다. + +예상 명령: + +```bash +working-diary notion views ensure +``` + +기본 view: + +- 작업 계층 +- 오늘 작업 +- 상태별 +- 목적별 +- 프로젝트별 +- 막힌 작업 + +View 자동화는 push 실패와 분리한다. view 생성/갱신 실패가 작업 기록 실패로 이어지면 안 된다. + +### Phase 3. Operations + +목표: 쌓인 DB를 읽어 상태를 점검하고 운영 정보를 계산한다. + +예상 명령: + +```bash +working-diary notion sync-status --dry-run +working-diary notion sync-status +``` + +기능 후보: + +- 하위 작업 기반 진행률 계산 +- 선행 작업 기반 blocked 탐지 +- 오래 방치된 작업 탐지 +- 검증 누락 탐지 +- 상위 작업 상태 제안 + +### Phase 4. Intelligence + +목표: 쌓인 작업 데이터를 바탕으로 우선순위, 리스크, 다음 액션을 제안한다. + +예상 명령: + +```bash +working-diary notion today-plan +working-diary notion review +working-diary notion weekly-brief +``` + +기능 후보: + +- 전날 남긴 todo/`next_steps` 기반 오늘 작업 우선순위 Top N 생성 +- 선행 작업이 완료된 후속 작업을 오늘 후보로 승격 +- 아직 막힌 작업은 blocker로 분리하고 우선순위 산정에서 제외하거나 낮춤 +- 프로젝트별 이번 주 요약 +- 다음 작업 우선순위 추천 +- 반복 이슈 탐지 +- 상사 보고용 brief 생성 +- 다음 세션 시작용 handoff 생성 + +`today-plan`은 하루 시작 시 사용하는 명령이다. 기본 출력은 제안이며 Notion 값을 변경하지 않는다. + +```text +오늘 작업 우선순위 + +1. 결제 진행 중 키패드 차단 + 이유: 전날 next_steps에 남았고 배리어프리 로컬 테스트 완료를 막고 있음 + +2. 테스트 DB 복구 상태 확인 + 이유: 상품 목록/결제 플로우 테스트의 선행 조건 + +3. Terraform 04-modules-basic 진행 + 이유: 01~03이 완료됐고 다음 학습 순서 +``` + +### Phase 5. Multi-project OS + +목표: Notion만이 아니라 Git/GitHub/로컬 문서/AI 세션을 연결한다. + +기능 후보: + +- GitHub issue/PR 연결 +- 프로젝트별 backlog와 diary 연결 +- commit/PR 기준 작업 회고 +- 여러 프로젝트의 오늘 우선순위 통합 +- 로컬 Markdown diary와 Notion DB 양방향 참조 + +## 7. Non-goals + +현재 비목표: + +- 사용자의 모든 Notion 수동 편집을 자동으로 추적하거나 병합 +- Notion을 완전한 Jira 대체제로 만드는 것 +- 매 push마다 view/status/review 자동화를 모두 실행 +- AI가 사용자 승인 없이 우선순위나 Status를 확정 변경 +- 과거 모든 diary를 자동 마이그레이션 + +## 8. 리스크 + +| 리스크 | 설명 | 대응 | +|--------|------|------| +| 과도한 row 분리 | 체크리스트 수준 항목까지 row가 되어 DB가 지저분해짐 | row 생성 기준을 skill에 유지 | +| 자동화의 과잉 판단 | Status/Priority를 잘못 바꿈 | dry-run, 수동 우선 원칙 | +| API 버전 변화 | Notion View/Data Source API가 변경됨 | view 자동화를 push와 분리 | +| relation 오용 | Parent와 Depends On이 섞임 | 문서/skill/test에서 의미 고정 | +| 본문 장황화 | page body가 다시 보고서처럼 길어짐 | compact body와 toggle 부록 유지 | + +## 9. 성공 기준 + +최고모델의 성공 기준은 기능 개수가 아니라 사용자의 다음 행동 판단이 쉬워졌는지다. + +- 작업 하나를 열면 무엇을 했는지 30초 안에 파악된다. +- 프로젝트 하나를 보면 진행 중/막힌 일/다음 액션이 보인다. +- 하루 시작 시 전날 todo와 미완료 작업을 다시 훑지 않아도 오늘 우선순위가 제안된다. +- 다음 세션을 시작할 때 이전 맥락을 다시 설명하지 않아도 된다. +- 상사 보고용 daily/weekly brief를 별도 정리 없이 만들 수 있다. +- 자동화가 사용자의 수동 판단을 방해하지 않는다. From 0fea9d81d0fa8d698dfab36abdd07555801cdf72 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 2 Jun 2026 10:52:36 +0900 Subject: [PATCH 15/30] docs: add notion core views design --- CHANGELOG.md | 1 + .../diary-notion-hierarchical.design.md | 2 + .../features/diary-notion-views.design.md | 759 ++++++++++++++++++ .../features/working-diary-os.vision.md | 73 +- 4 files changed, 830 insertions(+), 5 deletions(-) create mode 100644 docs/02-design/features/diary-notion-views.design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e07d81f..59cbe53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Notion 작업 DB 관계 구조**: `Parent Task` self-relation을 추가해 포함 관계를 DB 컬럼에 기록하고, 기존 `Depends On`은 선행 관계로 유지 - **접힌 근거 중심 Notion 본문**: page body를 callout/checklist/toggle 기반으로 압축해 상단은 요약과 상태, 부록은 코드·파일·명령어·Git·원문 요청 근거로 분리 - **Working Diary OS 비전 문서**: Structure → Views → Operations → Intelligence → Multi-project OS로 확장하는 최고모델 설계와 전날 todo 기반 `today-plan` 방향 추가 +- **2차 View 설계 문서**: `working-diary notion views ensure`와 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort, partial failure/exit code 정책 정리 - **`claude-diary write --input `**: Codex skill이 생성한 JSON으로 수동 Markdown 일지 작성 - **`lib/notion_cache.py`**: 연도 페이지/DB/행 ID 캐시 (root_page_id 변경 시 자동 무효화) - **`lib/git_info.py` 확장**: `get_branch_for_commit`, `get_head_branch`, `get_commit_info`, `get_diff_stat_for_commits` diff --git a/docs/02-design/features/diary-notion-hierarchical.design.md b/docs/02-design/features/diary-notion-hierarchical.design.md index b555cbd..8b40ab4 100644 --- a/docs/02-design/features/diary-notion-hierarchical.design.md +++ b/docs/02-design/features/diary-notion-hierarchical.design.md @@ -94,6 +94,8 @@ → 표시 13개 + hidden 2개 = 총 15개. 의미 요약은 컬럼이 아닌 compact body(`body_intro` + callout/checklist/toggle 부록)로 노출. +2차 View 설계에서는 이 v4 모델을 확장해 `Work Period` date range 컬럼을 추가하는 schema v5를 사용한다. `Date`는 기록일로 유지하고, `Work Period`는 프로젝트/작업 그룹의 실제 작업 기간 계산 재료로 사용한다. + **Purpose (select)**: - 영어 enum 사용: `Feature`, `Bugfix`, `Refactor`, `Docs`, `Test`, `Infra`, `Planning`, `Research`, `Review`, `Release`, `Support`, `Maintenance`, `General` - Notion에서 목적별 필터/그룹을 보장하는 1차 분류. 자동 view 생성은 후속 단계로 분리. diff --git a/docs/02-design/features/diary-notion-views.design.md b/docs/02-design/features/diary-notion-views.design.md new file mode 100644 index 0000000..bf7db84 --- /dev/null +++ b/docs/02-design/features/diary-notion-views.design.md @@ -0,0 +1,759 @@ +# /diary-notion Views — Core View Automation + +> **Summary**: 1차에서 만든 Notion 작업 DB 구조를 실제 업무 관리 화면으로 탐색할 수 있도록 core views를 자동 보장한다. +> +> **Project**: claude-code-hooks-diary +> **Date**: 2026-06-02 +> **Status**: Draft (설계 의논 중) + +> 상위 비전: [`working-diary-os.vision.md`](working-diary-os.vision.md) +> +> 선행 설계: [`diary-notion-hierarchical.design.md`](diary-notion-hierarchical.design.md) + +## Executive Summary + +| 관점 | 내용 | +|------|------| +| **Problem** | 1차에서 `Parent Task`, `Depends On`, `Project`, `Purpose`, `Status` 등 구조화 컬럼을 만들었지만 Notion 사용자는 여전히 직접 view를 구성해야 한다. | +| **Solution** | `working-diary notion views ensure` 명령으로 기본 탐색 화면인 Core Views를 자동 생성/보장한다. | +| **Core Value** | 같은 작업 row를 계층, 오늘, 상태, 목적, 프로젝트 관점으로 즉시 탐색할 수 있게 한다. | + +--- + +## 1. Core Views 원칙 + +Core Views는 모든 작업 row가 공통으로 가지는 DB 컬럼을 기준으로 제공하는 기본 화면이다. + +MVP 5개 view는 임시가 아니라 최종 모델에서도 유지되는 core view다. + +`작업 그룹별`은 2차 후속 core view로 둔다. + +Blocked/stale/today-plan/weekly brief는 core가 아니라 3차 이후 운영/지능화 view로 분리한다. + +### 1.1 왜 Core Views인가 + +2차 View 자동화는 화면을 많이 만드는 작업이 아니다. 1차에서 만든 공통 데이터 모델을 사용자가 실제로 탐색할 수 있게 만드는 최소 화면을 자동 보장하는 작업이다. + +공통 데이터 모델: + +```text +Name +Date +Work Period +Project +Purpose +Branch +Status +Task Group +Parent Task +Depends On +Categories +Files +Commits +Lines +Session ID +Task Index +``` + +Core Views는 이 공통 컬럼을 다른 질문으로 재배열한다. + +```text +작업 계층 = 이 일은 어떤 큰 작업의 일부인가 +오늘 작업 = 오늘 기록된 수행분은 무엇인가 +상태별 = 어디까지 진행됐는가 +목적별 = 어떤 성격의 일인가 +프로젝트별 = 어느 프로젝트의 일인가 +``` + +`Date`와 `Work Period`는 의미가 다르다. + +```text +Date = 기록일. 오늘 작업 view의 기준 +Work Period = 실제 작업 기간. 프로젝트/작업 그룹 산출물의 기간 계산 재료 +``` + +--- + +## 2. 2차 MVP Scope + +### 2.1 명령 + +```bash +working-diary notion views ensure +``` + +역할: + +- 현재 연도 Entries DB와 schema v5를 보장한다. +- 현재 설정된 hierarchical Notion DB에 core view가 있는지 확인한다. +- 없는 view만 생성한다. +- 같은 이름의 view가 있으면 required 설정을 검사한다. +- required 설정을 충족하면 verified로 처리한다. +- required 설정이 맞지 않으면 기존 view를 자동 수정하지 않고 conflict로 보고한다. +- 기존 row는 생성/수정/삭제하지 않는다. +- 실패해도 `/diary-notion` 또는 `$diary-notion` push 기능에 영향을 주지 않는다. + +### 2.2 MVP Required Core Views + +2차 MVP에서 자동 보장할 view는 다음 5개다. + +| View | 주 질문 | 기준 컬럼 | +|------|---------|-----------| +| 작업 계층 | 이 일은 어떤 큰 작업의 일부인가? | `Parent Task`, `Task Group` | +| 오늘 작업 | 오늘 기록된 수행분은 무엇인가? | `Date`, `Status` | +| 상태별 | 어디까지 진행됐는가? | `Status` | +| 목적별 | 어떤 성격의 일인가? | `Purpose` | +| 프로젝트별 | 어느 프로젝트의 일인가? | `Project`, `Work Period` | + +### 2.3 Core Follow-up + +`작업 그룹별`은 core follow-up으로 둔다. + +| View | 주 질문 | 기준 컬럼 | 이유 | +|------|---------|-----------|------| +| 작업 그룹별 | 며칠/여러 세션에 걸친 큰 작업 흐름은 무엇인가? | `Task Group` | 최고모델에서는 중요하지만 MVP 5개보다 우선순위가 낮다. | + +### 2.4 3차 이후로 미루는 View + +다음 view는 core가 아니라 운영/지능화 view다. + +| View | 단계 | 보류 이유 | +|------|------|-----------| +| 막힌 작업 | Phase 3 Operations | `Blocked` 컬럼과 blocked 계산 규칙이 필요하다. | +| 오래 방치된 작업 | Phase 3 Operations | stale 기준과 마지막 검토일 계산이 필요하다. | +| 검증 대기 | Phase 3 Operations | status와 verification 누락 규칙이 필요하다. | +| 오늘 우선순위 | Phase 4 Intelligence | 전날 todo/`next_steps`와 우선순위 scoring이 필요하다. | +| 주간 보고 | Phase 4 Intelligence | summary/review 생성 로직이 필요하다. | + +--- + +## 3. View 정의 + +### 3.1 작업 계층 + +목적: + +- `Parent Task` 기반으로 큰 작업과 하위 작업을 탐색한다. +- 1차에서 추가한 포함 관계를 사용자가 실제로 확인하게 한다. +- 메인 작업을 두고, 메인 작업을 수행하기 위한 세부 작업을 하위 항목으로 연결한다. + +2차 MVP는 **하위 항목 데이터 구조**를 필수로 보장한다. + +작업 계층 view는 메인 작업과 하위 작업의 `Parent Task` 관계를 반드시 노출한다. + +Notion의 접기/펼치기 sub-item UI는 최종 목표이며, 2차에서는 API 지원 여부에 따라 best-effort로 활성화한다. + +예시: + +```text +Working Diary OS + Notion 작업 계층 1차 구조 구현 + Working Diary OS 최고모델 비전 정리 + 2차 View 설계 +``` + +초기 표시 컬럼: + +- `Name` +- `Status` +- `Project` +- `Purpose` +- `Task Group` +- `Parent Task` +- `Depends On` +- `Work Period` +- `Date` + +정렬/그룹: + +- 가능하면 `Parent Task` 기반 sub-item 표시를 활성화한다. +- sub-item UI 자동화가 API 제약으로 어렵다면, MVP에서는 `Parent Task` 컬럼 표시와 `Task Group` 정렬/그룹으로 대체한다. + +필수 성공 기준: + +- `작업 계층` view가 생성된다. +- `Parent Task` 컬럼이 표시된다. +- 메인 작업과 하위 작업의 포함 관계를 view에서 확인할 수 있다. + +Best-effort 기준: + +- Notion table의 접기/펼치기 sub-item UI를 활성화한다. +- sub-item UI 설정 실패는 전체 `views ensure` 실패로 보지 않고 warning으로 보고한다. + +### 3.2 오늘 작업 + +목적: + +- 오늘 기록된 작업을 빠르게 확인한다. +- 오늘 실제로 수행하고 `$diary-notion`으로 남긴 작업 row를 확인한다. +- 이후 `today-plan`이 제안하는 오늘 후보와 구분되는 기록용 기본 화면이다. + +초기 기준: + +```text +Date = today +Sort: Date desc +``` + +초기 표시 컬럼: + +- `Name` +- `Status` +- `Project` +- `Purpose` +- `Task Group` +- `Work Period` +- `Parent Task` +- `Depends On` + +주의: + +- `오늘 작업`은 “오늘 해야 할 일”이 아니라 “오늘 기록된 작업”이다. +- 어제부터 이어진 작업을 오늘도 수행했다면 오늘 수행분을 새 row로 남긴다. +- 이어진 작업은 같은 `Task Group`을 사용해 여러 날짜의 수행분을 하나의 흐름으로 묶는다. +- 오늘 수행분을 `$diary-notion`으로 새로 기록하지 않았다면 `오늘 작업` view에는 나타나지 않는다. +- `Date`는 오늘로 기록하고, `Work Period`는 오늘 수행분의 실제 작업일 또는 작업 구간을 기록한다. +- 같은 push 안에서 메인 작업과 세부 작업이 함께 생성된 경우에는 `Parent Task`로 포함 관계를 연결한다. +- 과거 row를 찾아 cross-day `Parent Task` relation으로 자동 연결하는 것은 2차 MVP 범위가 아니며, 필요하면 후속 작업으로 분리한다. +- 2차 MVP에서는 “오늘 해야 할 작업 추천”까지 하지 않는다. +- 추천은 Phase 4 `today-plan`에서 처리한다. + +예시: + +```text +어제 row +Date = 2026-06-01 +Work Period = 2026-06-01 +Task Group = diary-notion-view-design + +오늘 row +Date = 2026-06-02 +Work Period = 2026-06-02 +Task Group = diary-notion-view-design +``` + +### 3.3 상태별 + +목적: + +- 작업이 Discussion, Design, Implementation, Testing, Deployed 중 어디에 있는지 확인한다. + +그룹: + +```text +Group by Status +``` + +초기 표시 컬럼: + +- `Name` +- `Project` +- `Purpose` +- `Task Group` +- `Parent Task` +- `Work Period` +- `Date` + +상태 순서: + +```text +Discussion +Design +Implementation +Testing +Deployed +``` + +### 3.4 목적별 + +목적: + +- Feature, Bugfix, Planning, Docs 등 작업 성격을 기준으로 회고/보고 관점을 제공한다. + +그룹: + +```text +Group by Purpose +``` + +초기 표시 컬럼: + +- `Name` +- `Status` +- `Project` +- `Task Group` +- `Work Period` +- `Date` + +활용: + +- 주간 회고에서 구현/버그수정/문서/설계 비중을 확인한다. +- 상사 보고용 요약의 근거가 된다. + +### 3.5 프로젝트별 + +목적: + +- 여러 repo/프로젝트에서 생성된 row를 프로젝트 기준으로 분리해서 본다. + +그룹: + +```text +Group by Project +``` + +초기 표시 컬럼: + +- `Name` +- `Status` +- `Purpose` +- `Task Group` +- `Parent Task` +- `Work Period` +- `Date` + +활용: + +- `claude-code-hooks-diary`, `flow-chatbot`, `zelotek` 등 여러 작업이 섞여도 프로젝트별 흐름을 확인한다. +- 최종 Multi-project OS의 기본 진입점이 된다. + +--- + +## 4. 자동화 정책 + +### 4.1 DB/schema 보장 + +`views ensure`는 현재 연도 Entries DB와 schema v5까지 보장한다. + +```text +working-diary notion views ensure +→ year page 확인/생성 +→ Entries DB 확인/생성 +→ schema v5 확인/보강 +→ core views 확인/생성 +``` + +정책: + +- DB가 없으면 현재 연도 기준으로 생성할 수 있다. +- schema가 오래됐으면 core view 생성에 필요한 v5 schema까지 보강한다. +- schema v5는 기존 v4에 `Work Period` date range 컬럼을 추가한다. +- 기존 row는 생성/수정/삭제하지 않는다. +- view 생성 전 root page, year, database 상태를 CLI 출력에 표시한다. +- 이 명령의 DB/schema 보장은 view 생성의 전제 조건을 맞추기 위한 것이며 작업 기록 push를 대신하지 않는다. + +예상 출력: + +```text +[working-diary notion views ensure] +Root page: ... +Year: 2026 +Database: Entries (created) +Schema: v5 ensured +Views: + + 작업 계층 + + 오늘 작업 + + 상태별 + + 목적별 + + 프로젝트별 +``` + +이미 모두 있으면: + +```text +[working-diary notion views ensure] +Root page: ... +Year: 2026 +Database: Entries (existing) +Schema: v5 already ensured +Views: + = 작업 계층 (verified) + = 오늘 작업 (verified) + = 상태별 (verified) + = 목적별 (verified) + = 프로젝트별 (verified) +``` + +### 4.2 기본은 non-destructive + +View 자동화는 사용자의 수동 Notion 편집을 존중한다. + +- `views ensure`는 core view를 create + verify 한다. +- 이름이 같은 view가 있으면 무조건 skip하지 않고 required 설정을 검사한다. +- required 설정을 충족하면 verified로 처리한다. +- required 설정이 맞지 않으면 기존 view를 자동 수정하지 않고 conflict로 보고한다. +- 없는 view만 required 설정으로 create 한다. +- 기존 view의 수동 설정을 덮어쓰지 않음 +- 기존 row는 수정하지 않음 +- conflict가 있으면 core view 보장이 실패한 것이므로 exit 1을 반환한다. +- view 생성/검증 실패가 diary push 실패로 전파되지 않음 + +### 4.3 Force는 후속 + +2차 MVP에서는 `--force`를 필수로 구현하지 않는다. + +후속 옵션: + +```bash +working-diary notion views ensure --force +``` + +예상 동작: + +- 시스템이 관리하는 view만 재생성 또는 업데이트 +- 사용자 정의 view는 건드리지 않음 +- 적용 전 변경 계획을 출력 + +### 4.4 Push와 분리 + +`$diary-notion`은 작업 기록에 집중한다. + +```text +$diary-notion +→ row 생성 +→ Parent Task / Depends On 연결 +→ body blocks 기록 +``` + +View 자동화는 별도 명령으로 둔다. + +```text +working-diary notion views ensure +→ 현재 연도 Entries DB/schema v5 보장 +→ core view 존재 확인 +→ 없으면 생성 +→ 있으면 required 설정 검증 +→ required 설정 미충족 시 conflict 보고 +``` + +이 분리를 유지하는 이유: + +- view는 매 push마다 만들 필요가 없다. +- View API 권한/버전 문제가 작업 기록 실패로 이어지면 안 된다. +- 사용자가 원하는 시점에 화면 구성을 갱신할 수 있다. + +### 4.5 기본 설정 보장 범위 + +공식 Views API 확인 결과, table view 생성/수정 시 다음 설정은 API 모델 안에서 직접 다룰 수 있다. + +- `filter` +- `sorts` +- `configuration.properties` +- `configuration.group_by` +- `configuration.subtasks` +- `wrap_cells` +- `frozen_column_index` +- `show_vertical_lines` + +따라서 2차 MVP의 “이름 + 기본 설정 보장”은 이름만 만드는 수준이 아니다. Core view 생성 시 다음을 required 설정으로 둔다. + +Required: + +- view 이름 +- view type: `table` +- core property 표시 +- hidden property 숨김 +- view별 최소 filter/sort/group +- `Work Period` 컬럼 표시 +- `작업 계층` view의 `Parent Task` 기반 subtask configuration payload 구성 + +Required mismatch: + +- view type이 `table`이 아님 +- `Session ID`, `Task Index`가 표시됨 +- `Work Period` 컬럼이 표시되지 않음 +- `오늘 작업`에 `Date = today` filter가 없음 +- `상태별`에 `Status` group_by가 없음 +- `목적별`에 `Purpose` group_by가 없음 +- `프로젝트별`에 `Project` group_by가 없음 +- `작업 계층`에 `Parent Task` 컬럼 표시가 없음 + +Best-effort: + +- Notion UI의 접기/펼치기 sub-item 렌더링이 실제 workspace에서 기대대로 활성화되는지 +- group order 세부 순서 +- column width, frozen column, wrap, vertical line 같은 presentation detail +- view tab 위치 + +Best-effort mismatch는 warning만 남기고 실패로 보지 않는다. + +`subtasks`는 API상 table configuration의 일부로 지원되므로 생성 payload에는 포함한다. 다만 workspace/API 버전/권한/Notion 동작 차이로 sub-item UI 설정이 거절되거나 기대와 다르게 보일 수 있으므로, 이 실패는 2차 MVP의 전체 실패가 아니라 warning으로 처리한다. + +Core view별 required 설정: + +| View | Required 설정 | +|------|---------------| +| 작업 계층 | `table`, 핵심 properties 표시, `Parent Task` 표시, `Work Period` 표시, `subtasks.property_id = Parent Task`, `display_mode = show`, `filter_scope = parents_and_subitems` | +| 오늘 작업 | `table`, `Date = today` filter, `Date desc` sort, `Work Period` 표시, 핵심 properties 표시 | +| 상태별 | `table`, `Status` group_by, `Work Period` 표시, 핵심 properties 표시 | +| 목적별 | `table`, `Purpose` group_by, `Work Period` 표시, 핵심 properties 표시 | +| 프로젝트별 | `table`, `Project` group_by, `Work Period` 표시, 핵심 properties 표시 | + +모든 core view에서 기본적으로 숨긴다: + +- `Session ID` +- `Task Index` + +다음 컬럼은 view별로 표시가 필요하지 않으면 숨길 수 있다. + +- `Files` +- `Commits` +- `Lines` +- `Categories` +- `Branch` + +확인한 공식 문서: + +- Notion Developers: Working with views +- Notion Developers: Upgrading to 2025-09-03 +- Notion Developers: Upgrading to 2026-03-11 + +--- + +## 5. API 경계 + +### 5.1 API version 분리 정책 + +Notion API version은 workspace나 database를 전역 업그레이드하는 값이 아니라, 요청마다 `Notion-Version` header로 선택하는 값이다. + +2차 MVP에서는 Notion API version을 전역으로 올리지 않는다. + +결정: + +```text +기존 기록 경로 +NotionHierarchicalExporter +Notion-Version: 2022-06-28 +역할: DB 생성, schema 보강, row 생성, body blocks append, relation 연결 + +신규 view 경로 +NotionViewsClient +Notion-Version: 2025-09-03 +역할: data_source_id 확인, view 조회, core view 생성 +``` + +이유: + +- `$diary-notion` push 경로는 이미 작업 기록의 핵심 경로이므로 안정성이 가장 중요하다. +- `2025-09-03`부터 Notion이 database와 data source를 더 명확히 분리했기 때문에 기존 push 경로를 통째로 올리면 DB 생성, relation, page parent, query 쪽 영향 범위가 커진다. +- `views ensure`는 Views API가 필요하므로 `2025-09-03` 이상이 필수다. +- 따라서 view 자동화만 새 API version을 쓰고, 기존 기록 기능은 안정 버전에 남긴다. + +`2026-03-11`은 2차 MVP에서 바로 적용하지 않는다. 이 버전은 view 생성 때문에 필수인 버전이 아니며, block append의 `after` → `position`, `archived` → `in_trash`, `transcription` → `meeting_notes` 변경이 있어 기존 push/body append 경로까지 함께 검토해야 한다. + +후속으로 `2026-03-11`을 적용할 때는 전체 Notion API 호출 목록을 기준으로 별도 migration 문서를 만든다. + +### 5.2 기존 push 경로 + +기존 row push는 안정성이 중요하므로 현재 구조를 유지한다. + +```text +NotionHierarchicalExporter +Notion-Version: 2022-06-28 +역할: year page, database, row, schema, relation +``` + +`views ensure`는 view 생성 전 이 경로의 `ensure_database(year)`를 재사용해 현재 연도 DB와 schema v5를 보장한다. + +### 5.3 view 자동화 경로 + +Views API는 별도 client로 분리한다. + +```text +NotionViewsExporter 또는 NotionViewsClient +Notion-Version: 2025-09-03 +역할: view 조회, view 생성, view 설정 +``` + +구현 시 고려: + +- `database_id`와 `data_source_id` 관계 확인 +- property id 조회 +- existing view 조회 +- 같은 이름 view required 설정 검사 +- required 설정 충족 시 verified 처리 +- required 설정 미충족 시 conflict 처리 +- API 버전 변경 영향 격리 + +공식 API 확인 사항: + +- Views API는 API version `2025-09-03` 이상이 필요하다. +- 2차 MVP의 view client는 `2025-09-03`을 사용한다. +- 2026-06-02 기준 공식 문서에는 `2026-03-11` 업그레이드도 존재하지만, 기존 body append/push 경로까지 함께 검토해야 하므로 후속 단계로 둔다. +- view 생성에는 `data_source_id`, `name`, `type`이 필요하고, top-level database view에는 `database_id`가 필요하다. +- filter/sorts는 data source query와 같은 shape를 사용한다. +- table configuration은 `properties`, `group_by`, `subtasks`를 지원한다. +- property configuration은 `visible`, `width`, `wrap`, date/time format 같은 표시 설정을 지원한다. +- subtask configuration은 self-referencing relation property를 사용해 parent-child hierarchy를 표시한다. + +### 5.4 실패 처리 + +View ensure 실패는 다음처럼 처리한다. + +```text +Auth/permission error → 명확한 안내 후 종료 +Bad request → view 이름과 요청 payload 요약 출력 +Network/rate limit → retry 또는 재실행 안내 +Partial failure → 성공/실패 view를 나눠 보고 +``` + +작업 기록 push와 다르게, view ensure는 실패해도 데이터 손실이 없다. + +### 5.5 exit code와 partial failure + +`views ensure`는 core view 자동 보장 명령이므로 core view 생성 실패와 best-effort 실패를 구분한다. + +| 상황 | exit code | rollback | 이유 | +|------|-----------|----------|------| +| core view 전부 생성 | 0 | 없음 | 성공 | +| core view 전부 verified | 0 | 없음 | 성공 | +| 기존 core view required 설정 conflict | 1 | 없음 | 보장 실패 | +| 일부 core view 실패 | 1 | 없음 | partial failure | +| 인증/권한 실패 | 1 | 없음 | 전제 실패 | +| DB/schema 보장 실패 | 1 | 없음 | 전제 실패 | +| sub-item UI 설정 실패 | 0 | 없음 | best-effort warning | + +정책: + +- Core view 생성 실패는 partial failure로 보고 exit 1을 반환한다. +- 기존 view가 required 설정을 충족하지 못하면 conflict로 보고 exit 1을 반환한다. +- 이미 생성된 view는 rollback하지 않는다. +- rollback이 기존 사용자 view나 새로 생성된 정상 view를 건드릴 수 있으므로 더 위험하다. +- sub-item UI 설정 실패는 best-effort warning이며 exit code에 영향을 주지 않는다. +- warning은 CLI 출력에 남겨 사용자가 추후 수동 설정하거나 후속 개선을 요청할 수 있게 한다. + +내부 결과 모델 후보: + +```python +{ + "created": ["작업 계층", "오늘 작업"], + "verified": ["상태별"], + "conflicts": [("목적별", "missing Purpose group_by")], + "failed": [("프로젝트별", "Notion API 400 ...")], + "warnings": [("작업 계층", "sub-item UI unsupported")] +} +``` + +CLI 출력 예시: + +```text +[working-diary notion views ensure] +Database: Entries (existing) +Schema: v5 already ensured +Views: + + 작업 계층 + + 오늘 작업 + ! 상태별 -- Notion API 400: ... + x 목적별 -- conflict: missing Purpose group_by + = 프로젝트별 (verified) +Warnings: + ! 작업 계층 sub-item UI not enabled: unsupported by current API +``` + +--- + +## 6. 구현 순서 + +### Step 1. 설계 고정 + +- Core Views 5개 확정 +- `작업 그룹별`은 core follow-up으로 명시 +- Operations/Intelligence views는 3차 이후로 분리 +- `views ensure`가 현재 연도 Entries DB와 schema v5를 보장한다고 명시 + +### Step 2. CLI 뼈대 + +예상 명령: + +```bash +working-diary notion views ensure +claude-diary notion views ensure +``` + +CLI 구조 후보: + +```text +claude_diary.cli.notion_views + cmd_notion_views_ensure(args) +``` + +### Step 3. DB/schema 보장 + +기존 hierarchical exporter를 재사용한다. + +```text +NotionHierarchicalExporter.ensure_database(year) +→ year page 보장 +→ Entries DB 보장 +→ schema v5 보장 +``` + +이 단계에서 row는 만들지 않는다. + +### Step 4. View client 추가 + +후보 파일: + +```text +src/claude_diary/exporters/notion_views.py +``` + +역할: + +- credential resolve는 기존 notion push/init과 공유 +- target database/data source 확인 +- existing views 조회 +- existing views required 설정 검증 +- missing views 생성 + +### Step 5. 테스트 + +테스트는 네트워크 없이 mock 기반으로 작성한다. + +필수 테스트: + +- DB가 없으면 현재 연도 DB와 schema를 보장하는 경로를 호출 +- 이미 있고 required 설정을 충족하는 view는 verified +- 이미 있지만 required 설정이 부족한 view는 conflict +- 없는 core view는 create +- partial failure를 report +- conflict가 있으면 exit 1 +- 일부 core view 실패 시 exit 1 +- sub-item UI best-effort 실패는 warning만 남기고 exit 0 +- credential missing 시 안내 +- 기존 row를 수정하지 않음 +- push 경로와 view 경로가 분리되어 있음 +- `작업 그룹별`, `막힌 작업`, `today-plan` view는 MVP에서 생성하지 않음 + +--- + +## 7. Non-goals + +2차 MVP에서 하지 않는다. + +- `$diary-notion` 실행마다 view ensure 자동 실행 +- `Blocked`, `Progress`, `Priority` 컬럼 추가 +- stale/blocked/review/today-plan view 생성 +- weekly brief 생성 +- 프로젝트/작업 그룹 전체 기간 자동 계산 +- 기존 사용자 view 강제 수정 +- 기존 row 생성/수정/삭제 +- 기존 DB 전체 마이그레이션 +- Notion을 Jira처럼 완전한 issue tracker로 만드는 것 + +--- + +## 8. 성공 기준 + +2차 MVP 성공 기준: + +- `working-diary notion views ensure`가 현재 연도 Entries DB와 schema v5를 보장한다. +- `Work Period` date range 컬럼을 보장하고 core view에 표시한다. +- `working-diary notion views ensure`가 core view 5개를 자동 보장한다. +- 같은 이름의 view가 이미 있으면 중복 생성하지 않는다. +- 같은 이름의 view가 required 설정을 충족하면 verified로 처리한다. +- 같은 이름의 view가 required 설정을 충족하지 않으면 conflict로 보고 exit 1을 반환한다. +- 기존 row는 수정하지 않는다. +- 기존 `$diary-notion` push는 변경 없이 계속 동작한다. +- 사용자가 Notion DB에서 작업 계층, 오늘 작업, 상태별, 목적별, 프로젝트별로 즉시 탐색할 수 있다. +- 3차 Operations와 4차 Intelligence view는 core view와 혼동되지 않는다. diff --git a/docs/02-design/features/working-diary-os.vision.md b/docs/02-design/features/working-diary-os.vision.md index 428c2c1..dc82f8d 100644 --- a/docs/02-design/features/working-diary-os.vision.md +++ b/docs/02-design/features/working-diary-os.vision.md @@ -129,6 +129,7 @@ row로 만들 기준: |------|------| | `Name` | 작업 제목 | | `Date` | 작업 기록일 | +| `Work Period` | 실제 작업 기간. 프로젝트/작업 그룹 기간 계산 재료 | | `Project` | 프로젝트 필터/그룹 | | `Purpose` | Feature, Bugfix, Planning 등 목적 | | `Status` | Discussion, Design, Implementation, Testing, Deployed | @@ -154,6 +155,48 @@ row로 만들 기준: 확장 컬럼은 기존 핵심 모델을 대체하지 않고 보조한다. +### 4.3 Work Period 적용 원칙 + +`Date`와 `Work Period`는 최종 모델에서도 분리한다. + +```text +Date = 기록일 +Work Period = 실제 작업 기간 +``` + +`오늘 작업` view와 daily brief는 `Date`를 기준으로 한다. 사용자가 오늘 `$diary-notion`으로 남긴 수행분을 보여주는 것이 목적이기 때문이다. + +프로젝트 산출물, 작업 그룹, 주간/월간 회고의 실제 작업 기간은 `Work Period`를 기준으로 계산한다. + +집계 규칙: + +```text +Project duration += 같은 Project row들의 min(Work Period.start) ~ max(Work Period.end) + +Task Group duration += 같은 Task Group row들의 min(Work Period.start) ~ max(Work Period.end) + +Work days += Work Period가 포함하는 날짜의 unique day count +``` + +어제 시작한 작업을 오늘 이어서 했다면 오늘 수행분은 새 row로 남기고 같은 `Task Group`으로 묶는다. 기존 row의 `Work Period`를 자동으로 늘리지 않는다. + +```text +2026-06-01 row +Date = 2026-06-01 +Work Period = 2026-06-01 +Task Group = diary-notion-view-design + +2026-06-02 row +Date = 2026-06-02 +Work Period = 2026-06-02 +Task Group = diary-notion-view-design +``` + +최종 모델에서는 이 row들을 읽어 프로젝트/작업 그룹 단위의 기간을 제안한다. 실제 요약 row, 요약 DB, `First Worked On`, `Last Worked On`, `Work Days` 같은 계산 컬럼을 만들지는 Phase 3 이후 별도 apply 단계로 둔다. + ## 5. 자동화 경계 ### 5.1 에이전트 책임 @@ -170,12 +213,13 @@ row로 만들 기준: - `Parent Task`와 `Depends On` relation을 row 생성 후 연결한다. - Notion schema/view/status 동기화는 명령 단위로 분리한다. -### 5.3 리뷰 엔진 책임 +### 5.3 운영/리뷰 엔진 책임 -4차 이후의 책임이다. +3차 이후의 책임이다. - 오래 방치된 작업을 찾는다. - 반복되는 리스크를 요약한다. +- `Project` 또는 `Task Group`별 `Work Period`의 최소 시작일과 최대 종료일을 계산해 실제 작업 기간을 제안한다. - 전날/최근 N일의 미완료 todo와 `next_steps`를 수집한다. - `Depends On`, `Blocked`, `Status`, `Task Group` 연속성을 반영해 오늘 우선순위를 제안한다. - 다음 액션 후보를 제안한다. @@ -205,14 +249,30 @@ row로 만들 기준: working-diary notion views ensure ``` -기본 view: +Core Views: - 작업 계층 -- 오늘 작업 +- 오늘 작업: 오늘 해야 할 일이 아니라 오늘 실제로 기록된 수행분 - 상태별 - 목적별 - 프로젝트별 + +Core follow-up: + +- 작업 그룹별 + +Operations/Intelligence views: + - 막힌 작업 +- 오래 방치된 작업 +- 검증 대기 +- 오늘 우선순위 +- 주간 보고 + +하위 항목 정책: + +- 메인 작업과 하위 작업의 `Parent Task` 데이터 구조는 2차에서 필수로 보장한다. +- Notion의 접기/펼치기 sub-item UI는 최종 목표이며 2차에서는 best-effort로 활성화한다. View 자동화는 push 실패와 분리한다. view 생성/갱신 실패가 작업 기록 실패로 이어지면 안 된다. @@ -231,6 +291,8 @@ working-diary notion sync-status - 하위 작업 기반 진행률 계산 - 선행 작업 기반 blocked 탐지 +- `Project`/`Task Group`별 실제 작업 기간 계산 +- `Work Period` 기반 work days 계산 - 오래 방치된 작업 탐지 - 검증 누락 탐지 - 상위 작업 상태 제안 @@ -252,7 +314,7 @@ working-diary notion weekly-brief - 전날 남긴 todo/`next_steps` 기반 오늘 작업 우선순위 Top N 생성 - 선행 작업이 완료된 후속 작업을 오늘 후보로 승격 - 아직 막힌 작업은 blocker로 분리하고 우선순위 산정에서 제외하거나 낮춤 -- 프로젝트별 이번 주 요약 +- 프로젝트별 이번 주 요약과 실제 작업 기간 요약 - 다음 작업 우선순위 추천 - 반복 이슈 탐지 - 상사 보고용 brief 생성 @@ -311,6 +373,7 @@ working-diary notion weekly-brief - 작업 하나를 열면 무엇을 했는지 30초 안에 파악된다. - 프로젝트 하나를 보면 진행 중/막힌 일/다음 액션이 보인다. +- 프로젝트나 작업 그룹을 모아 보면 실제 작업 기간이 파악된다. - 하루 시작 시 전날 todo와 미완료 작업을 다시 훑지 않아도 오늘 우선순위가 제안된다. - 다음 세션을 시작할 때 이전 맥락을 다시 설명하지 않아도 된다. - 상사 보고용 daily/weekly brief를 별도 정리 없이 만들 수 있다. From 7f384c27f898e358f815e9f888d75669cc1084d7 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 2 Jun 2026 11:02:55 +0900 Subject: [PATCH 16/30] docs: clarify notion subtasks fallback --- CHANGELOG.md | 2 +- .../features/diary-notion-views.design.md | 44 ++++++++++++++----- .../features/working-diary-os.vision.md | 1 + 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cbe53..4647391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Notion 작업 DB 관계 구조**: `Parent Task` self-relation을 추가해 포함 관계를 DB 컬럼에 기록하고, 기존 `Depends On`은 선행 관계로 유지 - **접힌 근거 중심 Notion 본문**: page body를 callout/checklist/toggle 기반으로 압축해 상단은 요약과 상태, 부록은 코드·파일·명령어·Git·원문 요청 근거로 분리 - **Working Diary OS 비전 문서**: Structure → Views → Operations → Intelligence → Multi-project OS로 확장하는 최고모델 설계와 전날 todo 기반 `today-plan` 방향 추가 -- **2차 View 설계 문서**: `working-diary notion views ensure`와 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort, partial failure/exit code 정책 정리 +- **2차 View 설계 문서**: `working-diary notion views ensure`와 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort/fallback, partial failure/exit code 정책 정리 - **`claude-diary write --input `**: Codex skill이 생성한 JSON으로 수동 Markdown 일지 작성 - **`lib/notion_cache.py`**: 연도 페이지/DB/행 ID 캐시 (root_page_id 변경 시 자동 무효화) - **`lib/git_info.py` 확장**: `get_branch_for_commit`, `get_head_branch`, `get_commit_info`, `get_diff_stat_for_commits` diff --git a/docs/02-design/features/diary-notion-views.design.md b/docs/02-design/features/diary-notion-views.design.md index bf7db84..21bf48a 100644 --- a/docs/02-design/features/diary-notion-views.design.md +++ b/docs/02-design/features/diary-notion-views.design.md @@ -166,19 +166,26 @@ Working Diary OS 정렬/그룹: -- 가능하면 `Parent Task` 기반 sub-item 표시를 활성화한다. -- sub-item UI 자동화가 API 제약으로 어렵다면, MVP에서는 `Parent Task` 컬럼 표시와 `Task Group` 정렬/그룹으로 대체한다. +- `Parent Task` 컬럼 표시는 required다. +- `Work Period` 컬럼 표시는 required다. +- `subtasks` configuration 적용은 시도하되, Notion UI의 접기/펼치기 렌더링 성공은 best-effort다. +- sub-item UI 자동화가 API 제약으로 실패하면 `subtasks`를 제거한 base table view로 fallback한다. 필수 성공 기준: - `작업 계층` view가 생성된다. - `Parent Task` 컬럼이 표시된다. +- `Work Period` 컬럼이 표시된다. - 메인 작업과 하위 작업의 포함 관계를 view에서 확인할 수 있다. Best-effort 기준: - Notion table의 접기/펼치기 sub-item UI를 활성화한다. -- sub-item UI 설정 실패는 전체 `views ensure` 실패로 보지 않고 warning으로 보고한다. +- sub-item UI 설정 실패 후 base table fallback이 성공하면 전체 `views ensure` 실패로 보지 않고 warning으로 보고한다. + +실패 기준: + +- fallback base table view까지 생성에 실패하면 core view 생성 실패로 보고 exit 1을 반환한다. ### 3.2 오늘 작업 @@ -455,7 +462,7 @@ Required: - hidden property 숨김 - view별 최소 filter/sort/group - `Work Period` 컬럼 표시 -- `작업 계층` view의 `Parent Task` 기반 subtask configuration payload 구성 +- `작업 계층` view의 `Parent Task` 컬럼 표시 Required mismatch: @@ -470,6 +477,7 @@ Required mismatch: Best-effort: +- `작업 계층` view의 `Parent Task` 기반 subtask configuration payload 구성 - Notion UI의 접기/펼치기 sub-item 렌더링이 실제 workspace에서 기대대로 활성화되는지 - group order 세부 순서 - column width, frozen column, wrap, vertical line 같은 presentation detail @@ -477,18 +485,28 @@ Best-effort: Best-effort mismatch는 warning만 남기고 실패로 보지 않는다. -`subtasks`는 API상 table configuration의 일부로 지원되므로 생성 payload에는 포함한다. 다만 workspace/API 버전/권한/Notion 동작 차이로 sub-item UI 설정이 거절되거나 기대와 다르게 보일 수 있으므로, 이 실패는 2차 MVP의 전체 실패가 아니라 warning으로 처리한다. +`subtasks`는 API상 table configuration의 일부로 지원되므로 생성 payload에는 우선 포함한다. 다만 workspace/API 버전/권한/Notion 동작 차이로 sub-item UI 설정이 거절되거나 기대와 다르게 보일 수 있으므로, 이 실패는 2차 MVP의 전체 실패가 아니라 warning으로 처리한다. + +`subtasks` 포함 create/update가 실패하면 CLI는 `subtasks`를 제거한 base table view 생성으로 fallback한다. fallback view가 생성되면 전체 명령은 성공으로 처리하되 warning을 출력한다. base table view 생성까지 실패하면 core view 생성 실패로 보고 exit 1을 반환한다. Core view별 required 설정: | View | Required 설정 | |------|---------------| -| 작업 계층 | `table`, 핵심 properties 표시, `Parent Task` 표시, `Work Period` 표시, `subtasks.property_id = Parent Task`, `display_mode = show`, `filter_scope = parents_and_subitems` | +| 작업 계층 | `table`, 핵심 properties 표시, `Parent Task` 표시, `Work Period` 표시 | | 오늘 작업 | `table`, `Date = today` filter, `Date desc` sort, `Work Period` 표시, 핵심 properties 표시 | | 상태별 | `table`, `Status` group_by, `Work Period` 표시, 핵심 properties 표시 | | 목적별 | `table`, `Purpose` group_by, `Work Period` 표시, 핵심 properties 표시 | | 프로젝트별 | `table`, `Project` group_by, `Work Period` 표시, 핵심 properties 표시 | +작업 계층 view의 best-effort 설정: + +| 설정 | 기준 | +|------|------| +| `subtasks.property_id` | `Parent Task` relation property id | +| `display_mode` | `show` | +| `filter_scope` | `parents_and_subitems` | + 모든 core view에서 기본적으로 숨긴다: - `Session ID` @@ -611,7 +629,8 @@ Partial failure → 성공/실패 view를 나눠 보고 | 일부 core view 실패 | 1 | 없음 | partial failure | | 인증/권한 실패 | 1 | 없음 | 전제 실패 | | DB/schema 보장 실패 | 1 | 없음 | 전제 실패 | -| sub-item UI 설정 실패 | 0 | 없음 | best-effort warning | +| `subtasks` 설정 실패 후 base table fallback 성공 | 0 | 없음 | best-effort warning | +| `subtasks` 설정 실패 후 base table fallback 실패 | 1 | 없음 | core view 생성 실패 | 정책: @@ -619,7 +638,8 @@ Partial failure → 성공/실패 view를 나눠 보고 - 기존 view가 required 설정을 충족하지 못하면 conflict로 보고 exit 1을 반환한다. - 이미 생성된 view는 rollback하지 않는다. - rollback이 기존 사용자 view나 새로 생성된 정상 view를 건드릴 수 있으므로 더 위험하다. -- sub-item UI 설정 실패는 best-effort warning이며 exit code에 영향을 주지 않는다. +- `subtasks` 설정 실패 후 base table fallback이 성공하면 best-effort warning이며 exit code에 영향을 주지 않는다. +- base table fallback까지 실패하면 core view 생성 실패이므로 exit 1을 반환한다. - warning은 CLI 출력에 남겨 사용자가 추후 수동 설정하거나 후속 개선을 요청할 수 있게 한다. 내부 결과 모델 후보: @@ -630,7 +650,7 @@ Partial failure → 성공/실패 view를 나눠 보고 "verified": ["상태별"], "conflicts": [("목적별", "missing Purpose group_by")], "failed": [("프로젝트별", "Notion API 400 ...")], - "warnings": [("작업 계층", "sub-item UI unsupported")] + "warnings": [("작업 계층", "subtasks fallback: base table created")] } ``` @@ -647,7 +667,7 @@ Views: x 목적별 -- conflict: missing Purpose group_by = 프로젝트별 (verified) Warnings: - ! 작업 계층 sub-item UI not enabled: unsupported by current API + ! 작업 계층 subtasks not enabled: base table fallback created ``` --- @@ -705,6 +725,7 @@ src/claude_diary/exporters/notion_views.py - existing views 조회 - existing views required 설정 검증 - missing views 생성 +- `subtasks` 포함 작업 계층 view 생성 실패 시 base table fallback ### Step 5. 테스트 @@ -719,7 +740,8 @@ src/claude_diary/exporters/notion_views.py - partial failure를 report - conflict가 있으면 exit 1 - 일부 core view 실패 시 exit 1 -- sub-item UI best-effort 실패는 warning만 남기고 exit 0 +- `subtasks` 설정 실패 후 base table fallback 성공 시 warning만 남기고 exit 0 +- `subtasks` 설정 실패 후 base table fallback 실패 시 exit 1 - credential missing 시 안내 - 기존 row를 수정하지 않음 - push 경로와 view 경로가 분리되어 있음 diff --git a/docs/02-design/features/working-diary-os.vision.md b/docs/02-design/features/working-diary-os.vision.md index dc82f8d..645d05c 100644 --- a/docs/02-design/features/working-diary-os.vision.md +++ b/docs/02-design/features/working-diary-os.vision.md @@ -273,6 +273,7 @@ Operations/Intelligence views: - 메인 작업과 하위 작업의 `Parent Task` 데이터 구조는 2차에서 필수로 보장한다. - Notion의 접기/펼치기 sub-item UI는 최종 목표이며 2차에서는 best-effort로 활성화한다. +- sub-item UI 설정이 실패하면 `Parent Task`와 `Work Period`가 표시되는 base table view로 fallback한다. View 자동화는 push 실패와 분리한다. view 생성/갱신 실패가 작업 기록 실패로 이어지면 안 된다. From 4252a9704c9e32e300f7036294d3d47414e71ed2 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 2 Jun 2026 11:46:01 +0900 Subject: [PATCH 17/30] docs: refine notion ensure design --- CHANGELOG.md | 4 +- .../features/diary-notion-views.design.md | 192 +++++++++++++++--- .../features/working-diary-os.vision.md | 71 ++++++- 3 files changed, 232 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4647391..4cb23e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **DB 자동 생성 스키마**: Name, Date, Project, Purpose, Branch, Status, Task Group, Parent Task, Categories, Files, Commits, Lines, Depends On, Session ID, Task Index - **Notion 작업 DB 관계 구조**: `Parent Task` self-relation을 추가해 포함 관계를 DB 컬럼에 기록하고, 기존 `Depends On`은 선행 관계로 유지 - **접힌 근거 중심 Notion 본문**: page body를 callout/checklist/toggle 기반으로 압축해 상단은 요약과 상태, 부록은 코드·파일·명령어·Git·원문 요청 근거로 분리 -- **Working Diary OS 비전 문서**: Structure → Views → Operations → Intelligence → Multi-project OS로 확장하는 최고모델 설계와 전날 todo 기반 `today-plan` 방향 추가 -- **2차 View 설계 문서**: `working-diary notion views ensure`와 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort/fallback, partial failure/exit code 정책 정리 +- **Working Diary OS 비전 문서**: Structure → Views → Operations → Intelligence → Multi-project OS로 확장하는 최고모델 설계, 최소 명령 원칙, 전날 todo 기반 `today-plan`, schema/view conflict drift 관리 방향 추가 +- **2차 View 설계 문서**: `working-diary notion ensure`와 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort/fallback, partial failure/exit code 정책 정리 - **`claude-diary write --input `**: Codex skill이 생성한 JSON으로 수동 Markdown 일지 작성 - **`lib/notion_cache.py`**: 연도 페이지/DB/행 ID 캐시 (root_page_id 변경 시 자동 무효화) - **`lib/git_info.py` 확장**: `get_branch_for_commit`, `get_head_branch`, `get_commit_info`, `get_diff_stat_for_commits` diff --git a/docs/02-design/features/diary-notion-views.design.md b/docs/02-design/features/diary-notion-views.design.md index 21bf48a..52deed7 100644 --- a/docs/02-design/features/diary-notion-views.design.md +++ b/docs/02-design/features/diary-notion-views.design.md @@ -15,7 +15,7 @@ | 관점 | 내용 | |------|------| | **Problem** | 1차에서 `Parent Task`, `Depends On`, `Project`, `Purpose`, `Status` 등 구조화 컬럼을 만들었지만 Notion 사용자는 여전히 직접 view를 구성해야 한다. | -| **Solution** | `working-diary notion views ensure` 명령으로 기본 탐색 화면인 Core Views를 자동 생성/보장한다. | +| **Solution** | `working-diary notion ensure` 명령으로 Notion 작업 DB 기반 상태와 Core Views를 자동 생성/보장한다. | | **Core Value** | 같은 작업 row를 계층, 오늘, 상태, 목적, 프로젝트 관점으로 즉시 탐색할 수 있게 한다. | --- @@ -79,7 +79,8 @@ Work Period = 실제 작업 기간. 프로젝트/작업 그룹 산출물의 기 ### 2.1 명령 ```bash -working-diary notion views ensure +working-diary notion ensure +claude-diary notion ensure ``` 역할: @@ -92,6 +93,8 @@ working-diary notion views ensure - required 설정이 맞지 않으면 기존 view를 자동 수정하지 않고 conflict로 보고한다. - 기존 row는 생성/수정/삭제하지 않는다. - 실패해도 `/diary-notion` 또는 `$diary-notion` push 기능에 영향을 주지 않는다. +- 사용자-facing 명령은 `notion ensure` 하나로 둔다. +- `views ensure`는 구현 내부의 view 보장 단계 이름으로만 사용하고, 사용자에게 별도 하위 명령으로 노출하지 않는다. ### 2.2 MVP Required Core Views @@ -181,7 +184,7 @@ Working Diary OS Best-effort 기준: - Notion table의 접기/펼치기 sub-item UI를 활성화한다. -- sub-item UI 설정 실패 후 base table fallback이 성공하면 전체 `views ensure` 실패로 보지 않고 warning으로 보고한다. +- sub-item UI 설정 실패 후 base table fallback이 성공하면 전체 `notion ensure` 실패로 보지 않고 warning으로 보고한다. 실패 기준: @@ -202,6 +205,33 @@ Date = today Sort: Date desc ``` +`Date = today`는 Notion relative date filter를 우선 사용한다. + +```json +{ + "filter": { + "property": "Date", + "date": { + "equals": "today" + } + } +} +``` + +Notion 공식 changelog 기준 relative date filter 값은 view filters에도 적용된다. 따라서 2차 MVP의 기본 payload는 relative today filter다. + +relative today filter 적용이 API validation에서 실패하면 CLI 실행일 기준 fixed date filter로 fallback한다. + +fixed date filter를 사용할 때의 기준일은 다음 순서로 결정한다. + +```text +1. 명시 설정 timezone +2. 로컬 환경 timezone +3. Asia/Seoul +``` + +fixed date filter fallback을 사용한 경우에는 CLI warning으로 다음 날 재실행 필요성을 안내한다. + 초기 표시 컬럼: - `Name` @@ -271,6 +301,8 @@ Testing Deployed ``` +group order 세부 순서는 2차 MVP에서 강제하지 않는다. `Status` group_by 자체가 required이고, 순서 제어는 best-effort로 둔다. + ### 3.4 목적별 목적: @@ -297,6 +329,8 @@ Group by Purpose - 주간 회고에서 구현/버그수정/문서/설계 비중을 확인한다. - 상사 보고용 요약의 근거가 된다. +빈 목적이나 분류 실패는 `General`로 남긴다. 2차 MVP는 `Purpose` group_by를 보장하지만, 빈 그룹 숨김이나 `General` 그룹 위치 조정은 best-effort로 둔다. + ### 3.5 프로젝트별 목적: @@ -323,6 +357,7 @@ Group by Project - `claude-code-hooks-diary`, `flow-chatbot`, `zelotek` 등 여러 작업이 섞여도 프로젝트별 흐름을 확인한다. - 최종 Multi-project OS의 기본 진입점이 된다. +- 프로젝트별 기간 계산은 2차 MVP에서 하지 않고, `Work Period` 표시만 보장한다. --- @@ -330,10 +365,10 @@ Group by Project ### 4.1 DB/schema 보장 -`views ensure`는 현재 연도 Entries DB와 schema v5까지 보장한다. +`notion ensure`는 현재 연도 Entries DB와 schema v5까지 보장한다. ```text -working-diary notion views ensure +working-diary notion ensure → year page 확인/생성 → Entries DB 확인/생성 → schema v5 확인/보강 @@ -352,7 +387,7 @@ working-diary notion views ensure 예상 출력: ```text -[working-diary notion views ensure] +[working-diary notion ensure] Root page: ... Year: 2026 Database: Entries (created) @@ -368,7 +403,7 @@ Views: 이미 모두 있으면: ```text -[working-diary notion views ensure] +[working-diary notion ensure] Root page: ... Year: 2026 Database: Entries (existing) @@ -385,7 +420,7 @@ Views: View 자동화는 사용자의 수동 Notion 편집을 존중한다. -- `views ensure`는 core view를 create + verify 한다. +- `notion ensure`는 내부적으로 core view를 create + verify 한다. - 이름이 같은 view가 있으면 무조건 skip하지 않고 required 설정을 검사한다. - required 설정을 충족하면 verified로 처리한다. - required 설정이 맞지 않으면 기존 view를 자동 수정하지 않고 conflict로 보고한다. @@ -393,8 +428,20 @@ View 자동화는 사용자의 수동 Notion 편집을 존중한다. - 기존 view의 수동 설정을 덮어쓰지 않음 - 기존 row는 수정하지 않음 - conflict가 있으면 core view 보장이 실패한 것이므로 exit 1을 반환한다. +- conflict가 있더라도 `오늘 작업 (Generated)` 같은 대체 view를 자동 생성하지 않는다. - view 생성/검증 실패가 diary push 실패로 전파되지 않음 +conflict 출력에는 view 이름, mismatch 이유, 해결 안내를 포함한다. + +```text +Views: + x 오늘 작업 -- conflict + reason: missing Date=today filter + action: rename/delete this view or fix the filter, then rerun + +Exit: 1 +``` + ### 4.3 Force는 후속 2차 MVP에서는 `--force`를 필수로 구현하지 않는다. @@ -402,7 +449,7 @@ View 자동화는 사용자의 수동 Notion 편집을 존중한다. 후속 옵션: ```bash -working-diary notion views ensure --force +working-diary notion ensure --force ``` 예상 동작: @@ -410,6 +457,7 @@ working-diary notion views ensure --force - 시스템이 관리하는 view만 재생성 또는 업데이트 - 사용자 정의 view는 건드리지 않음 - 적용 전 변경 계획을 출력 +- conflict 해결을 위해 대체 이름 view를 자동 생성하지 않음 ### 4.4 Push와 분리 @@ -425,7 +473,7 @@ $diary-notion View 자동화는 별도 명령으로 둔다. ```text -working-diary notion views ensure +working-diary notion ensure → 현재 연도 Entries DB/schema v5 보장 → core view 존재 확인 → 없으면 생성 @@ -469,7 +517,7 @@ Required mismatch: - view type이 `table`이 아님 - `Session ID`, `Task Index`가 표시됨 - `Work Period` 컬럼이 표시되지 않음 -- `오늘 작업`에 `Date = today` filter가 없음 +- `오늘 작업`에 relative today filter 또는 fixed today filter가 없음 - `상태별`에 `Status` group_by가 없음 - `목적별`에 `Purpose` group_by가 없음 - `프로젝트별`에 `Project` group_by가 없음 @@ -494,11 +542,23 @@ Core view별 required 설정: | View | Required 설정 | |------|---------------| | 작업 계층 | `table`, 핵심 properties 표시, `Parent Task` 표시, `Work Period` 표시 | -| 오늘 작업 | `table`, `Date = today` filter, `Date desc` sort, `Work Period` 표시, 핵심 properties 표시 | +| 오늘 작업 | `table`, relative today filter 또는 fixed today filter, `Date desc` sort, `Work Period` 표시, 핵심 properties 표시 | | 상태별 | `table`, `Status` group_by, `Work Period` 표시, 핵심 properties 표시 | | 목적별 | `table`, `Purpose` group_by, `Work Period` 표시, 핵심 properties 표시 | | 프로젝트별 | `table`, `Project` group_by, `Work Period` 표시, 핵심 properties 표시 | +Core view별 payload / verify 기준: + +| View | Create payload 기준 | Verify 기준 | +|------|----------------------|-------------| +| 작업 계층 | `type=table`, 핵심 properties visible, `Parent Task` visible, `Work Period` visible, `Date desc` sort, `subtasks` best-effort | table view, `Parent Task` visible, `Work Period` visible | +| 오늘 작업 | `type=table`, `Date equals today` relative filter, `Date desc` sort, 핵심 properties visible | table view, `Date = today` filter 또는 fixed date fallback filter, `Date desc` sort, `Work Period` visible | +| 상태별 | `type=table`, `Status` group_by, 핵심 properties visible | table view, `Status` group_by, `Work Period` visible | +| 목적별 | `type=table`, `Purpose` group_by, 핵심 properties visible | table view, `Purpose` group_by, `Work Period` visible | +| 프로젝트별 | `type=table`, `Project` group_by, 핵심 properties visible | table view, `Project` group_by, `Work Period` visible | + +`오늘 작업` fixed date fallback verify는 CLI가 생성한 날짜 값을 기준으로 한다. 기존 view가 fixed date filter를 가지고 있는데 날짜가 오늘이 아니면 required mismatch로 보고 conflict 처리한다. + 작업 계층 view의 best-effort 설정: | 설정 | 기준 | @@ -523,6 +583,8 @@ Core view별 required 설정: 확인한 공식 문서: - Notion Developers: Working with views +- Notion Developers: Filter data source entries +- Notion Developers: Changelog - relative date filter values - Notion Developers: Upgrading to 2025-09-03 - Notion Developers: Upgrading to 2026-03-11 @@ -547,14 +609,14 @@ Notion-Version: 2022-06-28 신규 view 경로 NotionViewsClient Notion-Version: 2025-09-03 -역할: data_source_id 확인, view 조회, core view 생성 +역할: data_source_id 확인, property id map 생성, view 조회, core view 생성 ``` 이유: - `$diary-notion` push 경로는 이미 작업 기록의 핵심 경로이므로 안정성이 가장 중요하다. - `2025-09-03`부터 Notion이 database와 data source를 더 명확히 분리했기 때문에 기존 push 경로를 통째로 올리면 DB 생성, relation, page parent, query 쪽 영향 범위가 커진다. -- `views ensure`는 Views API가 필요하므로 `2025-09-03` 이상이 필수다. +- `notion ensure`의 view 보장 단계는 Views API가 필요하므로 `2025-09-03` 이상이 필수다. - 따라서 view 자동화만 새 API version을 쓰고, 기존 기록 기능은 안정 버전에 남긴다. `2026-03-11`은 2차 MVP에서 바로 적용하지 않는다. 이 버전은 view 생성 때문에 필수인 버전이 아니며, block append의 `after` → `position`, `archived` → `in_trash`, `transcription` → `meeting_notes` 변경이 있어 기존 push/body append 경로까지 함께 검토해야 한다. @@ -571,7 +633,7 @@ Notion-Version: 2022-06-28 역할: year page, database, row, schema, relation ``` -`views ensure`는 view 생성 전 이 경로의 `ensure_database(year)`를 재사용해 현재 연도 DB와 schema v5를 보장한다. +`notion ensure`는 view 생성 전 이 경로의 `ensure_database(year)`를 재사용해 현재 연도 DB와 schema v5를 보장한다. ### 5.3 view 자동화 경로 @@ -583,10 +645,49 @@ Notion-Version: 2025-09-03 역할: view 조회, view 생성, view 설정 ``` +조회 순서: + +```text +1. NotionHierarchicalExporter.ensure_database(year) + → root page 확인 + → year page 확인/생성 + → Entries DB 확인/생성 + → schema v5 보장 + → database_id 반환 + +2. NotionViewsClient + → database_id로 database/data source 정보 조회 + → data_source_id 확보 + +3. data source schema 조회 + → property name → property id map 생성 + +4. required property 검증 + → Date + → Work Period + → Status + → Project + → Purpose + → Parent Task + → Task Group + → Session ID + → Task Index + +5. core view payload 생성 + → filter/group/sorts/subtasks에 property id 사용 + +6. existing views 조회 + → name 기준 매칭 + → 있으면 required 설정 verify + → 없으면 create +``` + 구현 시 고려: - `database_id`와 `data_source_id` 관계 확인 -- property id 조회 +- property id는 hard-code하지 않고 data source schema에서 조회 +- property name → property id map 생성 +- required property 누락 검사 - existing view 조회 - 같은 이름 view required 설정 검사 - required 설정 충족 시 verified 처리 @@ -604,6 +705,13 @@ Notion-Version: 2025-09-03 - property configuration은 `visible`, `width`, `wrap`, date/time format 같은 표시 설정을 지원한다. - subtask configuration은 self-referencing relation property를 사용해 parent-child hierarchy를 표시한다. +경계: + +- schema v5 보장은 기존 push 경로를 재사용한다. +- view payload 작성과 view 생성은 `NotionViewsClient`가 담당한다. +- property id는 절대 hard-code하지 않는다. +- 신규 컬럼이 추가되어도 data source schema에서 name → id map을 다시 만들어 view payload를 구성한다. + ### 5.4 실패 처리 View ensure 실패는 다음처럼 처리한다. @@ -615,11 +723,21 @@ Network/rate limit → retry 또는 재실행 안내 Partial failure → 성공/실패 view를 나눠 보고 ``` -작업 기록 push와 다르게, view ensure는 실패해도 데이터 손실이 없다. +전제 실패: + +```text +database_id 확보 실패 → exit 1 +data_source_id 확보 실패 → exit 1 +required property 누락 → schema v5 보장 실패 또는 property map 실패, exit 1 +property id 조회 실패 → view 생성 불가, exit 1 +existing view 조회 실패 → view 보장 실패, exit 1 +``` + +작업 기록 push와 다르게, view 보장 단계는 실패해도 데이터 손실이 없다. ### 5.5 exit code와 partial failure -`views ensure`는 core view 자동 보장 명령이므로 core view 생성 실패와 best-effort 실패를 구분한다. +`notion ensure`의 view 보장 단계는 core view 생성 실패와 best-effort 실패를 구분한다. | 상황 | exit code | rollback | 이유 | |------|-----------|----------|------| @@ -629,6 +747,7 @@ Partial failure → 성공/실패 view를 나눠 보고 | 일부 core view 실패 | 1 | 없음 | partial failure | | 인증/권한 실패 | 1 | 없음 | 전제 실패 | | DB/schema 보장 실패 | 1 | 없음 | 전제 실패 | +| relative today filter 실패 후 fixed date fallback 성공 | 0 | 없음 | best-effort warning | | `subtasks` 설정 실패 후 base table fallback 성공 | 0 | 없음 | best-effort warning | | `subtasks` 설정 실패 후 base table fallback 실패 | 1 | 없음 | core view 생성 실패 | @@ -638,6 +757,7 @@ Partial failure → 성공/실패 view를 나눠 보고 - 기존 view가 required 설정을 충족하지 못하면 conflict로 보고 exit 1을 반환한다. - 이미 생성된 view는 rollback하지 않는다. - rollback이 기존 사용자 view나 새로 생성된 정상 view를 건드릴 수 있으므로 더 위험하다. +- relative today filter 실패 후 fixed date fallback이 성공하면 best-effort warning이며 exit code에 영향을 주지 않는다. - `subtasks` 설정 실패 후 base table fallback이 성공하면 best-effort warning이며 exit code에 영향을 주지 않는다. - base table fallback까지 실패하면 core view 생성 실패이므로 exit 1을 반환한다. - warning은 CLI 출력에 남겨 사용자가 추후 수동 설정하거나 후속 개선을 요청할 수 있게 한다. @@ -657,7 +777,7 @@ Partial failure → 성공/실패 view를 나눠 보고 CLI 출력 예시: ```text -[working-diary notion views ensure] +[working-diary notion ensure] Database: Entries (existing) Schema: v5 already ensured Views: @@ -679,22 +799,25 @@ Warnings: - Core Views 5개 확정 - `작업 그룹별`은 core follow-up으로 명시 - Operations/Intelligence views는 3차 이후로 분리 -- `views ensure`가 현재 연도 Entries DB와 schema v5를 보장한다고 명시 +- `notion ensure`가 현재 연도 Entries DB와 schema v5를 보장한다고 명시 ### Step 2. CLI 뼈대 예상 명령: ```bash -working-diary notion views ensure -claude-diary notion views ensure +working-diary notion ensure +claude-diary notion ensure ``` CLI 구조 후보: ```text -claude_diary.cli.notion_views - cmd_notion_views_ensure(args) +claude_diary.cli.notion_ensure + cmd_notion_ensure(args) + ensure_schema() + ensure_views() + verify_views() ``` ### Step 3. DB/schema 보장 @@ -706,6 +829,7 @@ NotionHierarchicalExporter.ensure_database(year) → year page 보장 → Entries DB 보장 → schema v5 보장 +→ database_id 반환 ``` 이 단계에서 row는 만들지 않는다. @@ -722,6 +846,10 @@ src/claude_diary/exporters/notion_views.py - credential resolve는 기존 notion push/init과 공유 - target database/data source 확인 +- `database_id`로 `data_source_id` 확보 +- data source schema 조회 +- property name → property id map 생성 +- required property 누락 검사 - existing views 조회 - existing views required 설정 검증 - missing views 생성 @@ -734,9 +862,19 @@ src/claude_diary/exporters/notion_views.py 필수 테스트: - DB가 없으면 현재 연도 DB와 schema를 보장하는 경로를 호출 +- `ensure_database(year)`가 `database_id`를 반환 +- `database_id`로 `data_source_id`를 조회 +- data source schema로 property name → property id map 생성 +- required property 누락 시 exit 1 +- property id를 hard-code하지 않음 +- `오늘 작업` relative today filter 생성 +- relative today filter validation 실패 시 fixed date filter fallback + warning +- fixed date fallback view가 오늘 날짜가 아니면 conflict - 이미 있고 required 설정을 충족하는 view는 verified - 이미 있지만 required 설정이 부족한 view는 conflict - 없는 core view는 create +- `Status` group order 차이는 warning 또는 ignore이며 conflict가 아님 +- `Purpose`의 빈 값/`General` 그룹 위치 차이는 conflict가 아님 - partial failure를 report - conflict가 있으면 exit 1 - 일부 core view 실패 시 exit 1 @@ -753,7 +891,7 @@ src/claude_diary/exporters/notion_views.py 2차 MVP에서 하지 않는다. -- `$diary-notion` 실행마다 view ensure 자동 실행 +- `$diary-notion` 실행마다 `notion ensure` 자동 실행 - `Blocked`, `Progress`, `Priority` 컬럼 추가 - stale/blocked/review/today-plan view 생성 - weekly brief 생성 @@ -769,9 +907,9 @@ src/claude_diary/exporters/notion_views.py 2차 MVP 성공 기준: -- `working-diary notion views ensure`가 현재 연도 Entries DB와 schema v5를 보장한다. +- `working-diary notion ensure`가 현재 연도 Entries DB와 schema v5를 보장한다. - `Work Period` date range 컬럼을 보장하고 core view에 표시한다. -- `working-diary notion views ensure`가 core view 5개를 자동 보장한다. +- `working-diary notion ensure`가 core view 5개를 자동 보장한다. - 같은 이름의 view가 이미 있으면 중복 생성하지 않는다. - 같은 이름의 view가 required 설정을 충족하면 verified로 처리한다. - 같은 이름의 view가 required 설정을 충족하지 않으면 conflict로 보고 exit 1을 반환한다. diff --git a/docs/02-design/features/working-diary-os.vision.md b/docs/02-design/features/working-diary-os.vision.md index 645d05c..2d15236 100644 --- a/docs/02-design/features/working-diary-os.vision.md +++ b/docs/02-design/features/working-diary-os.vision.md @@ -42,12 +42,14 @@ $diary-notion 추가 명령은 필요할 때만 실행한다. ```bash -working-diary notion views ensure -working-diary notion sync-status --dry-run +working-diary notion ensure working-diary notion today-plan working-diary notion review +working-diary notion weekly-brief ``` +최종 모델에서도 사용자-facing 명령은 최소화한다. Notion 기반 정비는 `working-diary notion ensure` 하나를 기본 진입점으로 두고, schema/view/status/drift 관련 세부 작업은 내부 단계와 옵션으로 확장한다. + 최종적으로 사용자는 Notion DB를 열어 다음을 확인할 수 있어야 한다. - 작업 계층: 어떤 일이 어떤 큰 작업의 하위인지 @@ -219,13 +221,63 @@ Task Group = diary-notion-view-design - 오래 방치된 작업을 찾는다. - 반복되는 리스크를 요약한다. +- schema/view conflict를 drift 관리 대상으로 분류하고 해결 계획을 제안한다. - `Project` 또는 `Task Group`별 `Work Period`의 최소 시작일과 최대 종료일을 계산해 실제 작업 기간을 제안한다. - 전날/최근 N일의 미완료 todo와 `next_steps`를 수집한다. - `Depends On`, `Blocked`, `Status`, `Task Group` 연속성을 반영해 오늘 우선순위를 제안한다. - 다음 액션 후보를 제안한다. - 상사 보고용 daily/weekly brief를 생성한다. -리뷰 엔진은 기본적으로 제안만 한다. 실제 Status/Priority 변경은 별도 apply 단계가 필요하다. +리뷰 엔진은 기본적으로 제안만 한다. 실제 Status/Priority/schema/view 변경은 별도 apply 단계가 필요하다. + +### 5.4 Conflict / Drift 관리 + +최종 모델에서 conflict는 단순 실패 메시지가 아니라 시스템 drift 관리 대상이다. + +기본 흐름: + +```text +Conflict 감지 +→ 원인 분류 +→ 해결 계획 제안 +→ dry-run 출력 +→ 사용자 승인 시 apply +→ 변경 내역 기록 +``` + +명령 방향: + +```bash +working-diary notion ensure +working-diary notion ensure --plan +working-diary notion ensure --apply +working-diary notion ensure --force +``` + +동작: + +- `notion ensure`: conflict를 감지하고 이유와 수동 해결 안내를 출력한다. +- `notion ensure --plan`: 어떤 view/schema를 어떻게 고칠지 변경 계획만 출력한다. +- `notion ensure --apply`: 사용자가 승인한 변경만 적용한다. +- `notion ensure --force`: 시스템이 관리하는 view만 재생성하거나 업데이트한다. + +conflict 유형: + +| 유형 | 예시 | 기본 처리 | +|------|------|-----------| +| Name conflict | 같은 이름 view가 있지만 type/filter/group이 다름 | conflict, exit 1 | +| Required setting conflict | `오늘 작업`에 today filter 없음 | conflict, exit 1 | +| Presentation drift | column width, group order, wrap 차이 | warning | +| Unsupported capability | `subtasks` API 실패 | fallback + warning | +| Schema conflict | `Work Period` 누락 | schema ensure로 보강, 실패 시 exit 1 | + +원칙: + +- 자동화는 감지와 제안까지 기본값이다. +- 수정은 `--apply` 또는 `--force`가 있어야 한다. +- 사용자 수동 view는 기본적으로 보호한다. +- conflict 해결을 위해 `오늘 작업 (Generated)` 같은 대체 view를 자동 생성하지 않는다. +- 반복 conflict는 review/weekly brief에서 내부 운영 리스크로 요약할 수 있지만, 상사 보고용 핵심 성과와는 분리한다. ## 6. Phase Roadmap @@ -246,7 +298,7 @@ Task Group = diary-notion-view-design 예상 명령: ```bash -working-diary notion views ensure +working-diary notion ensure ``` Core Views: @@ -284,14 +336,17 @@ View 자동화는 push 실패와 분리한다. view 생성/갱신 실패가 작 예상 명령: ```bash -working-diary notion sync-status --dry-run -working-diary notion sync-status +working-diary notion ensure --dry-run +working-diary notion ensure --apply ``` 기능 후보: - 하위 작업 기반 진행률 계산 - 선행 작업 기반 blocked 탐지 +- schema/view conflict 유형 분류 +- conflict dry-run plan 출력 +- 반복 conflict 추적 - `Project`/`Task Group`별 실제 작업 기간 계산 - `Work Period` 기반 work days 계산 - 오래 방치된 작업 탐지 @@ -318,6 +373,7 @@ working-diary notion weekly-brief - 프로젝트별 이번 주 요약과 실제 작업 기간 요약 - 다음 작업 우선순위 추천 - 반복 이슈 탐지 +- weekly review에 view/schema drift 요약 - 상사 보고용 brief 생성 - 다음 세션 시작용 handoff 생성 @@ -347,6 +403,9 @@ working-diary notion weekly-brief - commit/PR 기준 작업 회고 - 여러 프로젝트의 오늘 우선순위 통합 - 로컬 Markdown diary와 Notion DB 양방향 참조 +- 여러 Notion DB/프로젝트의 schema/view drift 관리 +- 시스템 관리 view와 사용자 view 분리 +- 승인 기반 apply/force 운영 ## 7. Non-goals From e028c06830947adbcd979929d9da732b78f0abde Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 2 Jun 2026 11:52:07 +0900 Subject: [PATCH 18/30] docs: define notion ensure cli scope --- CHANGELOG.md | 2 +- .../features/diary-notion-views.design.md | 57 ++++++++++++++++++- .../features/working-diary-os.vision.md | 3 + 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb23e0..dc33baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Notion 작업 DB 관계 구조**: `Parent Task` self-relation을 추가해 포함 관계를 DB 컬럼에 기록하고, 기존 `Depends On`은 선행 관계로 유지 - **접힌 근거 중심 Notion 본문**: page body를 callout/checklist/toggle 기반으로 압축해 상단은 요약과 상태, 부록은 코드·파일·명령어·Git·원문 요청 근거로 분리 - **Working Diary OS 비전 문서**: Structure → Views → Operations → Intelligence → Multi-project OS로 확장하는 최고모델 설계, 최소 명령 원칙, 전날 todo 기반 `today-plan`, schema/view conflict drift 관리 방향 추가 -- **2차 View 설계 문서**: `working-diary notion ensure`와 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort/fallback, partial failure/exit code 정책 정리 +- **2차 View 설계 문서**: `working-diary notion ensure`, `--year`, `--dry-run`과 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort/fallback, partial failure/exit code 정책 정리 - **`claude-diary write --input `**: Codex skill이 생성한 JSON으로 수동 Markdown 일지 작성 - **`lib/notion_cache.py`**: 연도 페이지/DB/행 ID 캐시 (root_page_id 변경 시 자동 무효화) - **`lib/git_info.py` 확장**: `get_branch_for_commit`, `get_head_branch`, `get_commit_info`, `get_diff_stat_for_commits` diff --git a/docs/02-design/features/diary-notion-views.design.md b/docs/02-design/features/diary-notion-views.design.md index 52deed7..ac8d0c9 100644 --- a/docs/02-design/features/diary-notion-views.design.md +++ b/docs/02-design/features/diary-notion-views.design.md @@ -80,7 +80,11 @@ Work Period = 실제 작업 기간. 프로젝트/작업 그룹 산출물의 기 ```bash working-diary notion ensure +working-diary notion ensure --year 2026 +working-diary notion ensure --dry-run claude-diary notion ensure +claude-diary notion ensure --year 2026 +claude-diary notion ensure --dry-run ``` 역할: @@ -95,6 +99,8 @@ claude-diary notion ensure - 실패해도 `/diary-notion` 또는 `$diary-notion` push 기능에 영향을 주지 않는다. - 사용자-facing 명령은 `notion ensure` 하나로 둔다. - `views ensure`는 구현 내부의 view 보장 단계 이름으로만 사용하고, 사용자에게 별도 하위 명령으로 노출하지 않는다. +- `--year`는 대상 연도 페이지/DB를 명시한다. +- `--dry-run`은 생성/수정 없이 현재 접근 가능한 상태 기준으로 변경 계획만 출력한다. ### 2.2 MVP Required Core Views @@ -442,18 +448,54 @@ Views: Exit: 1 ``` -### 4.3 Force는 후속 +### 4.3 Dry-run과 후속 옵션 -2차 MVP에서는 `--force`를 필수로 구현하지 않는다. +2차 MVP에는 `--year`와 `--dry-run`을 포함한다. + +`--dry-run`은 실제 보장이 아니라 계획 출력이다. + +`--dry-run`에서 하는 것: + +- credential 확인 +- root/year/database 접근 가능 여부 확인 +- schema v5 보강 필요 여부 계산 +- core view 생성/verify 계획 출력 +- conflict 예상 출력 +- 예상 exit code 출력 + +`--dry-run`에서 하지 않는 것: + +- year page 생성 +- database 생성 +- schema 보강 +- view 생성 +- view 수정 +- 기존 row 생성/수정/삭제 + +DB가 없을 때의 dry-run 출력 예: + +```text +[working-diary notion ensure --dry-run] +Database: missing +Plan: + + create year page + + create Entries DB + + ensure schema v5 + + create 5 core views +``` 후속 옵션: ```bash +working-diary notion ensure --plan +working-diary notion ensure --apply working-diary notion ensure --force ``` 예상 동작: +- `--plan`: dry-run보다 상세한 변경 계획과 drift 해결안을 출력 +- `--apply`: 사용자가 승인한 변경만 적용 - 시스템이 관리하는 view만 재생성 또는 업데이트 - 사용자 정의 view는 건드리지 않음 - 적용 전 변경 계획을 출력 @@ -807,7 +849,11 @@ Warnings: ```bash working-diary notion ensure +working-diary notion ensure --year 2026 +working-diary notion ensure --dry-run claude-diary notion ensure +claude-diary notion ensure --year 2026 +claude-diary notion ensure --dry-run ``` CLI 구조 후보: @@ -818,6 +864,7 @@ claude_diary.cli.notion_ensure ensure_schema() ensure_views() verify_views() + plan_ensure() ``` ### Step 3. DB/schema 보장 @@ -862,6 +909,10 @@ src/claude_diary/exporters/notion_views.py 필수 테스트: - DB가 없으면 현재 연도 DB와 schema를 보장하는 경로를 호출 +- `--year`가 대상 연도를 지정 +- `--dry-run`은 생성/수정 없이 계획만 출력 +- `--dry-run`은 year page/database/schema/view를 만들지 않음 +- DB가 없는 `--dry-run`은 생성 계획만 출력 - `ensure_database(year)`가 `database_id`를 반환 - `database_id`로 `data_source_id`를 조회 - data source schema로 property name → property id map 생성 @@ -908,6 +959,8 @@ src/claude_diary/exporters/notion_views.py 2차 MVP 성공 기준: - `working-diary notion ensure`가 현재 연도 Entries DB와 schema v5를 보장한다. +- `working-diary notion ensure --year YYYY`가 지정 연도 Entries DB와 schema v5를 보장한다. +- `working-diary notion ensure --dry-run`이 생성/수정 없이 계획만 출력한다. - `Work Period` date range 컬럼을 보장하고 core view에 표시한다. - `working-diary notion ensure`가 core view 5개를 자동 보장한다. - 같은 이름의 view가 이미 있으면 중복 생성하지 않는다. diff --git a/docs/02-design/features/working-diary-os.vision.md b/docs/02-design/features/working-diary-os.vision.md index 2d15236..3f4855a 100644 --- a/docs/02-design/features/working-diary-os.vision.md +++ b/docs/02-design/features/working-diary-os.vision.md @@ -43,6 +43,7 @@ $diary-notion ```bash working-diary notion ensure +working-diary notion ensure --dry-run working-diary notion today-plan working-diary notion review working-diary notion weekly-brief @@ -249,6 +250,7 @@ Conflict 감지 ```bash working-diary notion ensure +working-diary notion ensure --dry-run working-diary notion ensure --plan working-diary notion ensure --apply working-diary notion ensure --force @@ -257,6 +259,7 @@ working-diary notion ensure --force 동작: - `notion ensure`: conflict를 감지하고 이유와 수동 해결 안내를 출력한다. +- `notion ensure --dry-run`: 생성/수정 없이 현재 상태 기준 계획만 출력한다. - `notion ensure --plan`: 어떤 view/schema를 어떻게 고칠지 변경 계획만 출력한다. - `notion ensure --apply`: 사용자가 승인한 변경만 적용한다. - `notion ensure --force`: 시스템이 관리하는 view만 재생성하거나 업데이트한다. From c38d9f9c7eca70f1863632f393160f9a2d75ce14 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 2 Jun 2026 14:07:24 +0900 Subject: [PATCH 19/30] feat: add diary notion core view ensure --- CHANGELOG.md | 7 +- README.en.md | 5 +- README.md | 8 +- docs/04-report/diary-notion-phase-2/README.md | 272 +++++++++ skills/diary-notion/SKILL.md | 6 +- src/claude_diary/cli/__init__.py | 24 +- src/claude_diary/cli/notion_ensure.py | 118 ++++ src/claude_diary/cli/notion_init.py | 2 +- src/claude_diary/cli/notion_push.py | 63 +- src/claude_diary/cli/setup.py | 14 +- .../exporters/notion_hierarchical.py | 102 +++- src/claude_diary/exporters/notion_views.py | 569 ++++++++++++++++++ tests/test_cli.py | 19 + tests/test_codex_plugin.py | 3 +- tests/test_notion_ensure.py | 96 +++ tests/test_notion_hierarchical.py | 113 +++- tests/test_notion_init.py | 2 +- tests/test_notion_push.py | 29 +- tests/test_notion_views.py | 254 ++++++++ tests/test_setup.py | 7 +- 20 files changed, 1639 insertions(+), 74 deletions(-) create mode 100644 docs/04-report/diary-notion-phase-2/README.md create mode 100644 src/claude_diary/cli/notion_ensure.py create mode 100644 src/claude_diary/exporters/notion_views.py create mode 100644 tests/test_notion_ensure.py create mode 100644 tests/test_notion_views.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dc33baf..ce321fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,15 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - LLM은 슬래시 커맨드 안의 Claude가 처리 — 별도 Anthropic API 키 불필요 - 멱등성: Session ID + Task Index 컬럼으로 skip, `--force` 시 archive&recreate - 에러 분기: 401/403 fail fast, 400 skip, 429/5xx retry, 404 자동 재생성 -- **`claude-diary notion init`**: 대화형 셋업 (token + 페이지 URL/ID + 권한 검증) -- **`claude-diary notion push --input ` `--force`**: 임시 JSON 파일 받아 Notion에 push +- **`claude-diary diary-notion init`**: 대화형 셋업 (token + 페이지 URL/ID + 권한 검증) +- **`claude-diary diary-notion push --input ` `--force`**: 임시 JSON 파일 받아 Notion에 push - **Codex 표준 지원**: `$diary`, `$diary-notion` skills + `.codex-plugin/plugin.json` - **중립 CLI alias**: `working-diary` 명령을 `claude-diary`와 동일하게 제공 - **DB 자동 생성 스키마**: Name, Date, Project, Purpose, Branch, Status, Task Group, Parent Task, Categories, Files, Commits, Lines, Depends On, Session ID, Task Index - **Notion 작업 DB 관계 구조**: `Parent Task` self-relation을 추가해 포함 관계를 DB 컬럼에 기록하고, 기존 `Depends On`은 선행 관계로 유지 - **접힌 근거 중심 Notion 본문**: page body를 callout/checklist/toggle 기반으로 압축해 상단은 요약과 상태, 부록은 코드·파일·명령어·Git·원문 요청 근거로 분리 - **Working Diary OS 비전 문서**: Structure → Views → Operations → Intelligence → Multi-project OS로 확장하는 최고모델 설계, 최소 명령 원칙, 전날 todo 기반 `today-plan`, schema/view conflict drift 관리 방향 추가 -- **2차 View 설계 문서**: `working-diary notion ensure`, `--year`, `--dry-run`과 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort/fallback, partial failure/exit code 정책 정리 +- **2차 View 설계 문서**: `working-diary diary-notion ensure`, `--year`, `--dry-run`과 Core Views 5개, `Work Period` 기반 schema v5 방향, `작업 그룹별` follow-up, 운영/지능화 view 분리 기준, 하위 항목 데이터 구조, sub-item UI best-effort/fallback, partial failure/exit code 정책 정리 +- **`working-diary diary-notion ensure` 구현**: schema v5 `Work Period` 보장, Core Views 5개 생성/검증, `--year`, `--dry-run`, non-destructive conflict reporting 지원 - **`claude-diary write --input `**: Codex skill이 생성한 JSON으로 수동 Markdown 일지 작성 - **`lib/notion_cache.py`**: 연도 페이지/DB/행 ID 캐시 (root_page_id 변경 시 자동 무효화) - **`lib/git_info.py` 확장**: `get_branch_for_commit`, `get_head_branch`, `get_commit_info`, `get_diff_stat_for_commits` diff --git a/README.en.md b/README.en.md index a69fd29..3c60606 100644 --- a/README.en.md +++ b/README.en.md @@ -133,12 +133,13 @@ Push the current session to a hierarchical Notion database as task-sized rows. U └── ... ``` -Rows include filterable/groupable/relational columns for `Project`, `Purpose`, `Task Group`, `Parent Task`, `Depends On`, `Branch`, `Status`, and `Categories`. Automatic Notion view creation is intentionally left as a later step. +Rows include filterable/groupable/relational columns for `Project`, `Purpose`, `Task Group`, `Parent Task`, `Depends On`, `Branch`, `Status`, `Work Period`, and `Categories`. Run `working-diary diary-notion ensure` to create or verify the 5 core Notion views. `Parent Task` represents containment, while `Depends On` represents prerequisite order. Each Notion page body stays compact with callouts/checklists, and developer evidence such as code changes, files, commands, Git, and original prompts is hidden in appendix toggles. Code changes are high-signal summaries, not full diffs; include only behavior, schema, CLI, user workflow, or verification-scope changes. Titles and narrative body content are written in Korean. File paths, commands, branches, commit hashes, code identifiers, and `Purpose`/`Status` enum values remain literal or English. ```bash -claude-diary notion init +claude-diary diary-notion init +working-diary diary-notion ensure /diary-notion # Claude Code $diary-notion # Codex ``` diff --git a/README.md b/README.md index c004b5b..c29fa12 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Codex skill은 repo의 Codex plugin으로 설치하거나 `claude-diary install └── ... ``` -한 세션의 의논/구현이 의미 단위로 N개 행으로 분리되어 들어갑니다. branch가 바뀌면 무조건 새 task로 분리. `Project`, `Purpose`, `Task Group`, `Parent Task`, `Depends On` 컬럼으로 Notion에서 필터/그룹/관계 조회가 가능하며, view 자동 생성은 후속 단계로 분리합니다. +한 세션의 의논/구현이 의미 단위로 N개 행으로 분리되어 들어갑니다. branch가 바뀌면 무조건 새 task로 분리. `Project`, `Purpose`, `Task Group`, `Parent Task`, `Depends On`, `Work Period` 컬럼으로 Notion에서 필터/그룹/관계 조회가 가능하며, `working-diary diary-notion ensure`로 core view 5개를 생성하거나 검증합니다. `Parent Task`는 포함 관계, `Depends On`은 선행 관계를 나타냅니다. 각 Notion 페이지 본문은 짧은 callout/checklist 중심으로 정리하고, 코드 변경·파일·명령어·Git·원문 요청은 접힌 부록(toggle)에 기록합니다. 코드 변경은 full diff가 아니라 동작/스키마/CLI/사용자 흐름/검증 범위를 바꾼 주요 변경만 남깁니다. 제목과 설명형 본문은 한국어로 기록하고, 파일 경로/명령어/branch/commit hash/코드 식별자 및 `Purpose`, `Status` enum 값은 원문 또는 영어 값을 유지합니다. @@ -144,7 +144,7 @@ Codex skill은 repo의 Codex plugin으로 설치하거나 `claude-diary install 3. **그 페이지를 Integration에 공유** — 페이지 우상단 ⋯ → "Connections" → 만든 Integration 추가 4. **셋업 명령 실행**: ```bash - claude-diary notion init + claude-diary diary-notion init ``` 대화형으로 token과 root page URL(또는 ID)을 입력하면 권한 검증 후 config에 저장됩니다. 5. **세션에서 `/diary-notion` 또는 `$diary-notion` 입력** — 작업 분리 + Notion push 자동 실행 @@ -153,7 +153,8 @@ Codex skill은 repo의 Codex plugin으로 설치하거나 `claude-diary install ```bash # 처음 한 번 -claude-diary notion init +claude-diary diary-notion init +working-diary diary-notion ensure # 매 세션 /diary-notion # Claude Code 세션 안에서 @@ -170,6 +171,7 @@ $diary-notion # Codex 세션 안에서 |------|------|------| | Name | title | Claude가 뽑은 task 제목 (명사구) | | Date | date | | +| Work Period | date | 실제 작업 기간. 프로젝트/작업 그룹 기간 계산 재료 | | Project | select | cwd 폴더명. group/filter용 | | Purpose | select | Feature/Bugfix/Refactor/Docs/Test/Infra/Planning/Research/Review/Release/Support/Maintenance/General | | Branch | select | task별 branch (group/filter용) | diff --git a/docs/04-report/diary-notion-phase-2/README.md b/docs/04-report/diary-notion-phase-2/README.md new file mode 100644 index 0000000..4c6745c --- /dev/null +++ b/docs/04-report/diary-notion-phase-2/README.md @@ -0,0 +1,272 @@ +# Diary Notion Phase 2 Implementation + +> 2차 구현은 Notion 작업 DB를 단순 기록 저장소에서 작업 관리 화면으로 확장하는 단계다. 이 문서는 구현 내용을 단계별로 계속 누적하기 위한 README다. + +## 목표 + +2차의 목표는 `$diary-notion`으로 쌓인 작업 row를 Notion 안에서 바로 탐색할 수 있게 만드는 것이다. + +- schema v5로 `Work Period`를 보장한다. +- `working-diary diary-notion ensure` 명령으로 DB schema와 core views를 보장한다. +- 기존 push 경로는 안정성을 유지하고, view 자동화는 별도 명령으로 분리한다. +- 기존 Notion view와 row는 자동으로 덮어쓰지 않는다. +- conflict는 감지하고 보고하되, 사용자가 직접 수정하거나 후속 apply 단계에서 처리한다. + +## 현재 구현 상태 + +| 항목 | 상태 | 내용 | +| --- | --- | --- | +| Schema v5 | 완료 | `Work Period` date 컬럼 추가 | +| Push 보강 | 완료 | 작업 row 생성 시 `Work Period` 기록 | +| Ensure CLI | 완료 | `working-diary diary-notion ensure`, `claude-diary diary-notion ensure` 지원 | +| Core Views | 완료 | 5개 core view 생성/검증 | +| Dry-run | 완료 | 생성/수정 없이 계획 출력 | +| Conflict 보고 | 완료 | 기존 view required 설정 mismatch 시 exit 1 | +| Subtasks fallback | 완료 | `작업 계층` sub-item 설정 실패 시 base table fallback | +| Relative today fallback | 완료 | `오늘 작업` relative today filter 실패 시 fixed date fallback | +| View 자동 수정 | 제외 | 2차 MVP에서는 기존 view를 자동 수정하지 않음 | + +## 단계별 구현 기록 + +### Step 1. Schema v5 보장 + +`NotionHierarchicalExporter`의 schema 버전을 `v5`로 올리고 `Work Period` date 컬럼을 추가했다. + +주요 내용: + +- 기존 v4 DB는 `Work Period`만 patch한다. +- v3 DB는 `Parent Task`와 `Work Period`를 patch한다. +- v2 DB는 `Purpose`, `Parent Task`, `Work Period`를 patch한다. +- schema 기록이 없거나 오래된 DB는 현재 extension schema 전체를 patch한다. +- `diary-notion ensure`에서는 cache가 `v5`여도 schema patch를 강제로 한 번 보내 보장성을 높인다. + +수정 파일: + +- `src/claude_diary/exporters/notion_hierarchical.py` +- `tests/test_notion_hierarchical.py` + +### Step 2. Work Period row 기록 + +`$diary-notion` push가 생성하는 각 작업 row에 `Work Period` 값을 넣도록 보강했다. + +입력 규칙: + +```json +{ + "work_period": "2026-06-02" +} +``` + +```json +{ + "work_period": { + "start": "2026-06-01", + "end": "2026-06-02" + } +} +``` + +지원 형태: + +- 값이 없으면 기록일 `Date`와 같은 날짜를 사용한다. +- `YYYY-MM-DD` 문자열을 지원한다. +- `YYYY-MM-DD..YYYY-MM-DD` 문자열 range를 지원한다. +- `{ "start": "...", "end": "..." }` 객체 range를 지원한다. + +수정 파일: + +- `src/claude_diary/cli/notion_push.py` +- `tests/test_notion_push.py` +- `skills/diary-notion/SKILL.md` +- `src/claude_diary/cli/setup.py` + +### Step 3. Ensure CLI 추가 + +사용자 facing 명령은 하나로 유지한다. + +```bash +working-diary diary-notion ensure +working-diary diary-notion ensure --year 2026 +working-diary diary-notion ensure --dry-run +claude-diary diary-notion ensure +``` + +동작: + +- 일반 실행은 year page, Entries DB, schema v5, core views를 보장한다. +- `--year`는 대상 연도를 명시한다. +- `--dry-run`은 생성/patch 없이 접근 가능한 현재 상태 기준으로 계획만 출력한다. +- DB가 없는 dry-run은 생성 계획만 출력하고 실제 Notion에는 쓰지 않는다. + +수정 파일: + +- `src/claude_diary/cli/__init__.py` +- `src/claude_diary/cli/notion_ensure.py` +- `tests/test_cli.py` +- `tests/test_notion_ensure.py` + +### Step 4. Views API client 분리 + +기존 push 경로와 view 자동화 경로를 분리했다. + +```text +NotionHierarchicalExporter + Notion-Version: 2022-06-28 + 역할: year page, Entries DB, schema, row, relation + +NotionViewsClient + Notion-Version: 2025-09-03 + 역할: data source, property id map, view list/retrieve/create +``` + +분리 이유: + +- push 경로는 이미 안정화된 기록 경로이므로 API version 변경 영향을 최소화해야 한다. +- Views API는 `2025-09-03` 이상이 필요하므로 별도 client가 맞다. +- view 생성 실패가 `$diary-notion` row 기록 실패로 이어지지 않게 한다. + +수정 파일: + +- `src/claude_diary/exporters/notion_views.py` +- `tests/test_notion_views.py` + +### Step 5. Core Views 5개 생성/검증 + +2차 MVP에서 보장하는 core views: + +| View | 목적 | Required 기준 | +| --- | --- | --- | +| 작업 계층 | 상위/하위 작업 탐색 | `Parent Task`, `Work Period` 표시 | +| 오늘 작업 | 오늘 기록한 수행분 확인 | `Date = today`, `Date desc`, `Work Period` 표시 | +| 상태별 | 진행 단계 확인 | `Status` group_by | +| 목적별 | 작업 성격별 확인 | `Purpose` group_by | +| 프로젝트별 | 프로젝트별 작업 확인 | `Project` group_by | + +검증 원칙: + +- 같은 이름의 view가 없으면 생성한다. +- 같은 이름의 view가 required 설정을 만족하면 verified 처리한다. +- 같은 이름의 view가 required 설정을 만족하지 않으면 자동 수정하지 않고 conflict로 보고한다. +- `Session ID`, `Task Index`는 hidden property로 유지한다. + +검증 보정: + +- Notion data source schema는 property id를 URL-encoded 형태로 반환할 수 있다. +- Notion view retrieve 응답은 같은 property id를 decoded 형태로 반환한다. +- 따라서 property id map 생성 시 id를 decode해 view 응답과 같은 기준으로 비교한다. +- 이 보정이 없으면 실제 view가 정상 생성되어도 dry-run에서 false conflict가 발생한다. + +### Step 6. Best-effort fallback + +두 가지는 core 성공 기준이 아니라 best-effort로 처리한다. + +`작업 계층` subtasks fallback: + +- 우선 `Parent Task` self-relation 기반 `subtasks` 설정을 포함해 생성한다. +- Notion API가 거절하면 `subtasks`를 제거한 base table view로 다시 생성한다. +- base table 생성이 성공하면 warning만 출력하고 exit 0을 유지한다. + +`오늘 작업` relative today fallback: + +- 우선 `Date equals today` relative filter로 생성한다. +- Notion API validation이 실패하면 실행일 기준 fixed date filter로 다시 생성한다. +- fixed date fallback이 성공하면 warning만 출력한다. + +### Step 7. 문서와 설치 지시문 반영 + +사용자가 새 세션에서도 같은 구조를 만들 수 있도록 README와 skill 지시문을 갱신했다. + +반영 내용: + +- README에 `working-diary diary-notion ensure` 명령 추가 +- README에 `Work Period` 컬럼 설명 추가 +- Codex skill JSON 예시에 `work_period` 추가 +- 설치용 embedded Codex skill에도 동일 지시문 반영 +- CHANGELOG에 구현 항목 추가 + +수정 파일: + +- `README.md` +- `README.en.md` +- `CHANGELOG.md` +- `skills/diary-notion/SKILL.md` +- `src/claude_diary/cli/setup.py` +- `tests/test_setup.py` +- `tests/test_codex_plugin.py` + +## 검증 + +실행한 검증: + +```bash +python -m pytest +``` + +결과: + +```text +620 passed +``` + +컴파일 확인: + +```bash +python -m compileall src +``` + +결과: + +```text +passed +``` + +## 사용 방법 + +처음 또는 view/schema를 정리하고 싶을 때: + +```bash +working-diary diary-notion ensure +``` + +특정 연도 DB를 보장할 때: + +```bash +working-diary diary-notion ensure --year 2026 +``` + +실제 변경 없이 계획만 볼 때: + +```bash +working-diary diary-notion ensure --dry-run +``` + +작업 기록은 기존처럼 유지한다. + +```bash +$diary-notion +``` + +## 운영 원칙 + +- `$diary-notion`은 작업 row 기록에 집중한다. +- `diary-notion ensure`는 DB schema와 core views 보장에 집중한다. +- view conflict는 자동 수정하지 않는다. +- 기존 row는 생성, 수정, 삭제하지 않는다. +- generated 대체 view를 자동으로 만들지 않는다. +- `--apply`, `--force`, `--plan`은 3차 이후 운영/지능화 단계에서 다룬다. + +## 남은 작업 + +2차 이후 후보: + +- 실제 Notion workspace에서 `working-diary diary-notion ensure --dry-run` 실행 결과 확인 +- 실제 Notion workspace에서 `working-diary diary-notion ensure` 실행 후 core views 렌더링 확인 +- `작업 그룹별` core follow-up view 추가 여부 결정 +- view conflict를 더 자세히 설명하는 `--plan` 출력 설계 +- 기존 수동 view를 보호하면서 system-managed view만 갱신하는 apply 모델 설계 + +## 관련 문서 + +- `docs/02-design/features/diary-notion-hierarchical.design.md` +- `docs/02-design/features/diary-notion-views.design.md` +- `docs/02-design/features/working-diary-os.vision.md` diff --git a/skills/diary-notion/SKILL.md b/skills/diary-notion/SKILL.md index d111648..80db9da 100644 --- a/skills/diary-notion/SKILL.md +++ b/skills/diary-notion/SKILL.md @@ -42,6 +42,7 @@ Split the current Codex session into task-sized entries and push them to Notion. - `support_needed`: 0-1 decisions or support needed from others - `status`: `Discussion`, `Design`, `Implementation`, `Testing`, or `Deployed` - `purpose`: `Feature`, `Bugfix`, `Refactor`, `Docs`, `Test`, `Infra`, `Planning`, `Research`, `Review`, `Release`, `Support`, `Maintenance`, or `General` + - `work_period`: actual work period; use today's `YYYY-MM-DD` by default, or `{"start":"YYYY-MM-DD","end":"YYYY-MM-DD"}` for a range - `task_group`: stable kebab-case/snake-case group for multi-session work - `parent_index`: zero-based index of the parent task in this push, or `null`; use it for "part of" hierarchy - `depends_on_indices`: zero-based indices in this push, or `[]` @@ -72,6 +73,7 @@ Split the current Codex session into task-sized entries and push them to Notion. "support_needed": ["..."], "status": "Implementation", "purpose": "Feature", + "work_period": "2026-06-02", "task_group": "working-diary-notion", "parent_index": null, "depends_on_indices": [], @@ -88,8 +90,8 @@ Split the current Codex session into task-sized entries and push them to Notion. } ``` -5. Run `working-diary notion push --input .diary-notion-<8-random>.json`. -6. If `working-diary` is not available, run `claude-diary notion push --input .diary-notion-<8-random>.json`. +5. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json`. +6. If `working-diary` is not available, run `claude-diary diary-notion push --input .diary-notion-<8-random>.json`. 7. Report pushed/skipped/failed tasks from the CLI output. If there are no task-worthy changes, explain that and do not call the CLI. diff --git a/src/claude_diary/cli/__init__.py b/src/claude_diary/cli/__init__.py index 76f1ce8..b050d1d 100644 --- a/src/claude_diary/cli/__init__.py +++ b/src/claude_diary/cli/__init__.py @@ -22,14 +22,28 @@ from claude_diary.cli.write import cmd_write from claude_diary.cli.notion_push import cmd_notion_push from claude_diary.cli.notion_init import cmd_notion_init +from claude_diary.cli.notion_ensure import cmd_notion_ensure def cmd_notion(args): - """Dispatch `notion ` to the right command.""" + """Dispatch `diary-notion|notion ` to the right command.""" if args.action == "push": cmd_notion_push(args) elif args.action == "init": cmd_notion_init(args) + elif args.action == "ensure": + cmd_notion_ensure(args) + + +def _add_diary_notion_parser(sub, name): + p_notion = sub.add_parser(name, help="Notion hierarchical work diary integration") + p_notion.add_argument("action", choices=["init", "push", "ensure"], help="Action to perform") + p_notion.add_argument("--input", help="JSON input file (push only)") + p_notion.add_argument("--force", action="store_true", + help="Archive prior rows for the session before pushing (push only)") + p_notion.add_argument("--year", type=int, help="Target year (ensure only)") + p_notion.add_argument("--dry-run", action="store_true", + help="Print the Notion schema/view plan without writing (ensure only)") def main(): @@ -131,11 +145,8 @@ def main(): p_write.add_argument("--input", help="JSON input file for agent-authored diary entries") # notion (hierarchical Notion DB integration — for /diary-notion slash command) - p_notion = sub.add_parser("notion", help="Notion hierarchical DB integration") - p_notion.add_argument("action", choices=["init", "push"], help="Action to perform") - p_notion.add_argument("--input", help="JSON input file (push only)") - p_notion.add_argument("--force", action="store_true", - help="Archive prior rows for the session before pushing (push only)") + _add_diary_notion_parser(sub, "diary-notion") + _add_diary_notion_parser(sub, "notion") args = parser.parse_args() @@ -160,6 +171,7 @@ def main(): "install": cmd_install, "uninstall": cmd_uninstall, "write": cmd_write, + "diary-notion": cmd_notion, "notion": cmd_notion, } diff --git a/src/claude_diary/cli/notion_ensure.py b/src/claude_diary/cli/notion_ensure.py new file mode 100644 index 0000000..18a8bbf --- /dev/null +++ b/src/claude_diary/cli/notion_ensure.py @@ -0,0 +1,118 @@ +"""`claude-diary diary-notion ensure` -- ensure Notion DB schema and core views.""" + +import sys +from datetime import datetime, timezone, timedelta + +from claude_diary.config import load_config +from claude_diary.log import configure_from_config +from claude_diary.cli.notion_push import _resolve_credentials, _print_setup_hint +from claude_diary.exporters.notion_hierarchical import ( + NotionHierarchicalExporter, + NotionAuthError, + NotionBadRequest, + NotionNotFound, + SCHEMA_VERSION, +) +from claude_diary.exporters.notion_views import ( + CORE_VIEW_NAMES, + CoreViewsEnsurer, + NotionViewsClient, +) + + +def cmd_notion_ensure(args): + """Ensure the current year's hierarchical Notion DB and 5 core views.""" + config = load_config() + configure_from_config(config) + + token, root_page_id = _resolve_credentials(config) + if not token or not root_page_id: + _print_setup_hint() + sys.exit(1) + + year, today = _resolve_year_and_today(config, getattr(args, "year", None)) + dry_run = getattr(args, "dry_run", False) is True + + exporter = NotionHierarchicalExporter({ + "api_token": token, + "root_page_id": root_page_id, + }) + exporter.load_cache() + + try: + if dry_run: + db_id = exporter.resolve_existing_database(year) + if not db_id: + _print_missing_database_plan(root_page_id, year) + return + schema_status = "%s would be verified" % SCHEMA_VERSION + else: + db_id = exporter.ensure_database(year, force_schema=True) + exporter.save_cache() + schema_status = "%s ensured" % SCHEMA_VERSION + + client = NotionViewsClient({"api_token": token}) + result = CoreViewsEnsurer(client).ensure(db_id, today, dry_run=dry_run) + except NotionAuthError as e: + print("[working-diary diary-notion ensure] Auth error: %s" % e, file=sys.stderr) + print(" Check: claude-diary config or run `claude-diary diary-notion init`", file=sys.stderr) + sys.exit(1) + except NotionNotFound as e: + print("[working-diary diary-notion ensure] Not found: %s" % e, file=sys.stderr) + print(" Check that the root page/database is shared with the integration.", file=sys.stderr) + sys.exit(1) + except NotionBadRequest as e: + print("[working-diary diary-notion ensure] Bad request: %s" % e, file=sys.stderr) + sys.exit(1) + except Exception as e: + print("[working-diary diary-notion ensure] Failed: %s" % e, file=sys.stderr) + sys.exit(1) + + _print_ensure_report(root_page_id, year, db_id, schema_status, result, dry_run) + if not result.ok(): + sys.exit(1) + + +def _resolve_year_and_today(config, explicit_year): + tz_offset = config.get("timezone_offset", 9) + local_tz = timezone(timedelta(hours=tz_offset)) + now = datetime.now(local_tz) + year = explicit_year or now.year + return year, now.strftime("%Y-%m-%d") + + +def _print_missing_database_plan(root_page_id, year): + print("[working-diary diary-notion ensure --dry-run]") + print("Root page: %s" % root_page_id) + print("Year: %s" % year) + print("Database: missing") + print("Plan:") + print(" + create year page if missing") + print(" + create Entries DB") + print(" + ensure schema %s" % SCHEMA_VERSION) + print(" + create %d core views" % len(CORE_VIEW_NAMES)) + + +def _print_ensure_report(root_page_id, year, db_id, schema_status, result, dry_run): + suffix = " --dry-run" if dry_run else "" + print("[working-diary diary-notion ensure%s]" % suffix) + print("Root page: %s" % root_page_id) + print("Year: %s" % year) + print("Database: %s" % db_id) + print("Schema: %s" % schema_status) + print("Views:") + for name in result.created: + print(" + %s" % name) + for name in result.planned: + print(" + %s (planned)" % name) + for name in result.verified: + print(" = %s (verified)" % name) + for conflict in result.conflicts: + print(" x %s -- conflict: %s" % (conflict.name, conflict.reason)) + print(" action: rename/delete this view or fix the required setting, then rerun") + for failure in result.failed: + print(" ! %s -- %s" % (failure.name, failure.reason)) + if result.warnings: + print("Warnings:") + for warning in result.warnings: + print(" ! %s" % warning) diff --git a/src/claude_diary/cli/notion_init.py b/src/claude_diary/cli/notion_init.py index 257d112..04c0bb9 100644 --- a/src/claude_diary/cli/notion_init.py +++ b/src/claude_diary/cli/notion_init.py @@ -1,4 +1,4 @@ -"""`claude-diary notion init` — interactive Notion setup. +"""`claude-diary diary-notion init` — interactive Notion setup. Walks the user through: 1. Pasting their integration token (https://www.notion.so/my-integrations) diff --git a/src/claude_diary/cli/notion_push.py b/src/claude_diary/cli/notion_push.py index 04edc73..3819916 100644 --- a/src/claude_diary/cli/notion_push.py +++ b/src/claude_diary/cli/notion_push.py @@ -1,4 +1,4 @@ -"""`claude-diary notion push` — push tasks JSON to the Notion hierarchical DB. +"""`claude-diary diary-notion push` — push tasks JSON to the Notion hierarchical DB. Driven by the `/diary-notion` slash command or `$diary-notion` Codex skill: 1. The agent writes `.diary-notion-.json` in cwd @@ -54,7 +54,7 @@ def cmd_notion_push(args): session_id = data.get("session_id") or _fallback_session_id() tasks = data.get("tasks") or [] if not tasks: - print("[claude-diary notion push] No tasks to push.") + print("[claude-diary diary-notion push] No tasks to push.") _cleanup(input_path) return @@ -80,10 +80,10 @@ def cmd_notion_push(args): try: db_id = exporter.ensure_database(year) archived = exporter.archive_rows_for_session(db_id, session_id) - print("[claude-diary notion push] --force: archived %d existing row(s)" % archived) + print("[claude-diary diary-notion push] --force: archived %d existing row(s)" % archived) except NotionAuthError as e: - print("[claude-diary notion push] Auth error: %s" % e, file=sys.stderr) - print(" Check: claude-diary config or run `claude-diary notion init`", file=sys.stderr) + print("[claude-diary diary-notion push] Auth error: %s" % e, file=sys.stderr) + print(" Check: claude-diary config or run `claude-diary diary-notion init`", file=sys.stderr) sys.exit(1) results = {"pushed": [], "skipped": [], "failed": []} @@ -212,13 +212,13 @@ def _resolve_credentials(config): def _read_json(path): if not path or not os.path.exists(path): - print("[claude-diary notion push] Input file not found: %s" % path, file=sys.stderr) + print("[claude-diary diary-notion push] Input file not found: %s" % path, file=sys.stderr) return None try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, IOError) as e: - print("[claude-diary notion push] Failed to read JSON: %s" % e, file=sys.stderr) + print("[claude-diary diary-notion push] Failed to read JSON: %s" % e, file=sys.stderr) return None @@ -326,6 +326,7 @@ def _build_properties(task, date_str, branch, git_info, session_id, task_index): props = { "Name": {"title": [{"text": {"content": title[:2000]}}]}, "Date": {"date": {"start": date_str}}, + "Work Period": {"date": _normalize_work_period(task.get("work_period"), date_str)}, "Project": {"select": {"name": _safe_select(project)}}, "Purpose": {"select": {"name": _normalize_purpose(task.get("purpose"))}}, "Categories": { @@ -351,6 +352,46 @@ def _build_properties(task, date_str, branch, git_info, session_id, task_index): return props +def _normalize_work_period(value, fallback_date): + """Return a Notion date object for the actual work period.""" + if isinstance(value, dict): + start = _clean_date(value.get("start") or value.get("date")) + end = _clean_date(value.get("end")) + if start: + result = {"start": start} + if end and end != start: + result["end"] = end + return result + elif isinstance(value, str): + raw = value.strip() + if raw: + if ".." in raw: + start, end = raw.split("..", 1) + result = {"start": _clean_date(start) or fallback_date} + end = _clean_date(end) + if end and end != result["start"]: + result["end"] = end + return result + clean = _clean_date(raw) + if clean: + return {"start": clean} + return {"start": fallback_date} + + +def _clean_date(value): + """Accept YYYY-MM-DD strings and ignore unsupported date values.""" + if not value: + return "" + raw = str(value).strip() + if len(raw) >= 10: + raw = raw[:10] + try: + datetime.strptime(raw, "%Y-%m-%d") + except ValueError: + return "" + return raw + + def _safe_select(name): """Notion select names cannot contain commas. Replace with '-'.""" return (name or "").replace(",", "-")[:100] or "unknown" @@ -370,7 +411,7 @@ def _print_report(results, input_path): pushed = len(results["pushed"]) skipped = len(results["skipped"]) failed = len(results["failed"]) - print("[claude-diary notion push] Pushed %d, skipped %d, failed %d" % + print("[claude-diary diary-notion push] Pushed %d, skipped %d, failed %d" % (pushed, skipped, failed)) for _, title in results["pushed"]: print(" + %s" % title) @@ -381,7 +422,7 @@ def _print_report(results, input_path): if failed > 0: print() print("Failed tasks preserved in: %s" % input_path) - print("Retry: claude-diary notion push --input %s --force" % input_path) + print("Retry: claude-diary diary-notion push --input %s --force" % input_path) def _cleanup(input_path): @@ -394,8 +435,8 @@ def _cleanup(input_path): def _print_setup_hint(): - print("[claude-diary notion push] Notion hierarchical exporter not configured.", + print("[claude-diary diary-notion push] Notion hierarchical exporter not configured.", file=sys.stderr) - print(" Run: claude-diary notion init", file=sys.stderr) + print(" Run: claude-diary diary-notion init", file=sys.stderr) print(" Or set CLAUDE_DIARY_NOTION_TOKEN and CLAUDE_DIARY_NOTION_ROOT_PAGE_ID", file=sys.stderr) diff --git a/src/claude_diary/cli/setup.py b/src/claude_diary/cli/setup.py index 6191352..10f8f67 100644 --- a/src/claude_diary/cli/setup.py +++ b/src/claude_diary/cli/setup.py @@ -86,6 +86,7 @@ - 한 task에 여러 단계 섞이면 **가장 진행된 단계로** (Testing 통과까지 했으면 Testing) - 결정만 했으면 Design, 코드 작성까지 했으면 Implementation, 테스트까지 했으면 Testing, 머지/배포까지 했으면 Deployed + - `work_period`: 실제 작업 기간. 기본은 오늘 날짜 `YYYY-MM-DD`; 여러 날에 걸친 수행분이면 `{"start":"YYYY-MM-DD","end":"YYYY-MM-DD"}` 사용 - `task_group`: 며칠/여러 세션에 걸치는 큰 작업 단위 식별자 (예: `diary-notion-impl`, `auth-refactor`) - 같은 큰 작업의 task들끼리 같은 그룹명 사용 → Notion에서 group view로 묶임 - 이전 작업의 연속이면 같은 그룹명, 새 작업이면 새 그룹명 (snake-case/kebab-case 권장) @@ -105,7 +106,7 @@ 4. **JSON 저장 및 CLI 호출** - cwd 에 `.diary-notion-<8자리random>.json` 파일을 Write 도구로 생성 - - `!claude-diary notion push --input .diary-notion-<8자리>.json` 실행 + - `!claude-diary diary-notion push --input .diary-notion-<8자리>.json` 실행 - 종료 후 임시 파일 삭제 (CLI도 try/finally로 삭제하지만 보험) ## JSON 형식 @@ -132,6 +133,7 @@ "next_steps": ["..."], "support_needed": ["..."], "status": "Implementation", + "work_period": "2026-06-02", "task_group": "diary-notion-impl", "purpose": "Feature", "parent_index": null, @@ -242,6 +244,7 @@ - `support_needed`: 0-1 decisions or support needed from others - `status`: `Discussion`, `Design`, `Implementation`, `Testing`, or `Deployed` - `purpose`: `Feature`, `Bugfix`, `Refactor`, `Docs`, `Test`, `Infra`, `Planning`, `Research`, `Review`, `Release`, `Support`, `Maintenance`, or `General` + - `work_period`: actual work period; use today's `YYYY-MM-DD` by default, or `{"start":"YYYY-MM-DD","end":"YYYY-MM-DD"}` for a range - `task_group`: stable kebab-case/snake-case group for multi-session work - `parent_index`: zero-based index of the parent task in this push, or `null`; use it for "part of" hierarchy - `depends_on_indices`: zero-based indices in this push, or `[]` @@ -272,6 +275,7 @@ "support_needed": ["..."], "status": "Implementation", "purpose": "Feature", + "work_period": "2026-06-02", "task_group": "working-diary-notion", "parent_index": null, "depends_on_indices": [], @@ -288,8 +292,8 @@ } ``` -5. Run `working-diary notion push --input .diary-notion-<8-random>.json`. -6. If `working-diary` is not available, run `claude-diary notion push --input .diary-notion-<8-random>.json`. +5. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json`. +6. If `working-diary` is not available, run `claude-diary diary-notion push --input .diary-notion-<8-random>.json`. 7. Report pushed/skipped/failed tasks from the CLI output. If there are no task-worthy changes, explain that and do not call the CLI. @@ -299,13 +303,13 @@ SLASH_COMMANDS = { # filename: (file content, marker substring used to detect "ours") "diary.md": (DIARY_SLASH_COMMAND, "claude-diary write"), - "diary-notion.md": (DIARY_NOTION_SLASH_COMMAND, "claude-diary notion push"), + "diary-notion.md": (DIARY_NOTION_SLASH_COMMAND, "claude-diary diary-notion push"), } CODEX_SKILLS = { "diary": (CODEX_DIARY_SKILL, "working-diary write --input"), - "diary-notion": (CODEX_DIARY_NOTION_SKILL, "working-diary notion push"), + "diary-notion": (CODEX_DIARY_NOTION_SKILL, "working-diary diary-notion push"), } diff --git a/src/claude_diary/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py index a23a765..76595dd 100644 --- a/src/claude_diary/exporters/notion_hierarchical.py +++ b/src/claude_diary/exporters/notion_hierarchical.py @@ -9,7 +9,7 @@ ├─ 2027 └─ ... -Used by `/diary-notion` and `$diary-notion` via `claude-diary notion push`. +Used by `/diary-notion` and `$diary-notion` via `claude-diary diary-notion push`. Separate from the flat-mode NotionExporter (Stop Hook auto-push). Error handling policy: @@ -32,7 +32,8 @@ NOTION_API_BASE = "https://api.notion.com/v1" MAX_RETRIES = 3 RICH_TEXT_LIMIT = 2000 -SCHEMA_VERSION = "v4" +SCHEMA_VERSION = "v5" +DATABASE_TITLE = "Entries" class NotionAuthError(Exception): @@ -51,7 +52,7 @@ class NotionHierarchicalExporter: """Pushes tasks to the year/database/row hierarchy. Unlike BaseExporter subclasses, this is invoked directly by the - `notion push` CLI with a list of tasks, not a single entry_data. + `diary-notion push` CLI with a list of tasks, not a single entry_data. """ def __init__(self, config): @@ -206,11 +207,11 @@ def _create_year_page(self, year): resp = self._request("POST", "/pages", body) return resp["id"] - def ensure_database(self, year): + def ensure_database(self, year, force_schema=False): """Get Entries database ID for a year, creating if missing. Also ensures the schema extensions (Purpose, Status, Task Group, Depends On, - Parent Task) + Parent Task, Work Period) are present — needed for older DBs created before those columns were part of the design. Tracked via cache so we only patch once. """ @@ -226,13 +227,58 @@ def ensure_database(self, year): if db_id is None: year_page_id = self.ensure_year_page(year) - db_id = self._create_database(year_page_id) + db_id = self._find_child_database(year_page_id, DATABASE_TITLE) + if db_id is None: + db_id = self._create_database(year_page_id) notion_cache.set_database(self._cache, year, db_id) - self._ensure_db_schema_extensions(db_id) + self._ensure_db_schema_extensions(db_id, force=force_schema) return db_id - def _ensure_db_schema_extensions(self, db_id): + def resolve_existing_database(self, year): + """Return an existing Entries DB ID without creating or patching anything.""" + cached = notion_cache.get_database(self._cache, year) + if cached: + try: + self._request("GET", "/databases/%s" % cached) + return cached + except NotionNotFound: + notion_cache.set_database(self._cache, year, None) + + year_page_id = notion_cache.get_year_page(self._cache, year) + if year_page_id: + try: + self._request("GET", "/blocks/%s" % year_page_id) + except NotionNotFound: + notion_cache.invalidate_year(self._cache, year) + year_page_id = None + + if not year_page_id: + year_page_id = self._find_child_page(self.root_page_id, str(year)) + if not year_page_id: + return None + + return self._find_child_database(year_page_id, DATABASE_TITLE) + + def _find_child_database(self, parent_id, title): + """Scan parent's children for a child_database with matching title.""" + cursor = None + while True: + path = "/blocks/%s/children?page_size=100" % parent_id + if cursor: + path += "&start_cursor=%s" % cursor + data = self._request("GET", path) + for block in data.get("results", []): + if block.get("type") != "child_database": + continue + block_title = block.get("child_database", {}).get("title", "") + if block_title == title: + return block["id"] + if not data.get("has_more"): + return None + cursor = data.get("next_cursor") + + def _ensure_db_schema_extensions(self, db_id, force=False): """Add current schema extensions if not yet recorded in cache. Patching the same property twice is harmless (Notion treats existing @@ -241,12 +287,27 @@ def _ensure_db_schema_extensions(self, db_id): """ schema_v = self._cache.setdefault("schema_v", {}) current = schema_v.get(db_id) - if current == SCHEMA_VERSION: + if current == SCHEMA_VERSION and not force: + return + if current == SCHEMA_VERSION and force: + self._request("PATCH", "/databases/%s" % db_id, { + "properties": _current_schema_extensions(db_id) + }) + schema_v[db_id] = SCHEMA_VERSION + return + if current == "v4": + self._request("PATCH", "/databases/%s" % db_id, { + "properties": { + "Work Period": {"date": {}}, + } + }) + schema_v[db_id] = SCHEMA_VERSION return if current == "v3": self._request("PATCH", "/databases/%s" % db_id, { "properties": { "Parent Task": _self_relation(db_id), + "Work Period": {"date": {}}, } }) schema_v[db_id] = SCHEMA_VERSION @@ -256,18 +317,13 @@ def _ensure_db_schema_extensions(self, db_id): "properties": { "Purpose": {"select": {}}, "Parent Task": _self_relation(db_id), + "Work Period": {"date": {}}, } }) schema_v[db_id] = SCHEMA_VERSION return self._request("PATCH", "/databases/%s" % db_id, { - "properties": { - "Purpose": {"select": {}}, - "Status": {"select": {}}, - "Task Group": {"select": {}}, - "Depends On": _self_relation(db_id), - "Parent Task": _self_relation(db_id), - } + "properties": _current_schema_extensions(db_id) }) schema_v[db_id] = SCHEMA_VERSION @@ -282,7 +338,7 @@ def _create_database(self, parent_page_id): body = { "parent": {"type": "page_id", "page_id": parent_page_id}, "is_inline": True, - "title": [{"text": {"content": "Entries"}}], + "title": [{"text": {"content": DATABASE_TITLE}}], "properties": { "Name": {"title": {}}, "Date": {"date": {}}, @@ -409,3 +465,15 @@ def _self_relation(db_id): "single_property": {}, } } + + +def _current_schema_extensions(db_id): + """Return the full current extension schema beyond the base DB columns.""" + return { + "Purpose": {"select": {}}, + "Status": {"select": {}}, + "Task Group": {"select": {}}, + "Depends On": _self_relation(db_id), + "Parent Task": _self_relation(db_id), + "Work Period": {"date": {}}, + } diff --git a/src/claude_diary/exporters/notion_views.py b/src/claude_diary/exporters/notion_views.py new file mode 100644 index 0000000..85605bf --- /dev/null +++ b/src/claude_diary/exporters/notion_views.py @@ -0,0 +1,569 @@ +"""Notion Views API client and core view ensure logic.""" + +import time +from dataclasses import dataclass, field +from urllib.parse import unquote, urlencode + +from claude_diary.exporters.notion_hierarchical import ( + NOTION_API_BASE, + MAX_RETRIES, + NotionAuthError, + NotionBadRequest, + NotionNotFound, +) + + +NOTION_VIEWS_API_VERSION = "2025-09-03" +CORE_VIEW_NAMES = ("작업 계층", "오늘 작업", "상태별", "목적별", "프로젝트별") + +REQUIRED_PROPERTIES = ( + "Name", + "Date", + "Work Period", + "Project", + "Purpose", + "Status", + "Task Group", + "Parent Task", + "Depends On", + "Session ID", + "Task Index", +) + +HIDDEN_PROPERTIES = ("Session ID", "Task Index") +SCHEMA_EXTENSION_PROPERTIES = ( + "Purpose", + "Status", + "Task Group", + "Depends On", + "Parent Task", + "Work Period", +) + + +@dataclass +class ViewConflict: + name: str + reason: str + + +@dataclass +class ViewFailure: + name: str + reason: str + + +@dataclass +class EnsureViewsResult: + created: list = field(default_factory=list) + verified: list = field(default_factory=list) + planned: list = field(default_factory=list) + conflicts: list = field(default_factory=list) + failed: list = field(default_factory=list) + warnings: list = field(default_factory=list) + + def ok(self): + return not self.conflicts and not self.failed + + +class NotionViewsClient: + """Small wrapper around Notion's 2025+ Views/Data source APIs.""" + + def __init__(self, config): + self.api_token = config.get("api_token") + + def validate_config(self): + return bool(self.api_token) + + def _ensure_requests(self): + try: + import requests + return requests + except ImportError: + raise RuntimeError( + "Notion views client requires 'requests'. Install with: pip install requests" + ) + + def _headers(self): + return { + "Authorization": "Bearer %s" % self.api_token, + "Content-Type": "application/json", + "Notion-Version": NOTION_VIEWS_API_VERSION, + } + + def _request(self, method, path, json_body=None): + requests = self._ensure_requests() + url = "%s%s" % (NOTION_API_BASE, path) + + last_error = None + for attempt in range(MAX_RETRIES): + try: + resp = requests.request( + method, + url, + headers=self._headers(), + json=json_body, + timeout=15, + ) + except Exception as e: + last_error = e + if attempt < MAX_RETRIES - 1: + time.sleep(2 ** attempt) + continue + raise RuntimeError("Notion Views API network error: %s" % e) + + status = resp.status_code + if status in (200, 201): + return resp.json() + + if status == 401 or status == 403: + raise NotionAuthError( + "Notion Views API %d: %s" % (status, _short_error(resp)) + ) + if status == 404: + raise NotionNotFound( + "Notion Views API 404: %s" % _short_error(resp) + ) + if status == 400: + raise NotionBadRequest( + "Notion Views API 400: %s" % _short_error(resp) + ) + if status == 429: + retry_after = int(resp.headers.get("Retry-After", "1")) + time.sleep(min(retry_after, 30)) + last_error = "rate limited" + continue + if 500 <= status < 600: + if attempt < MAX_RETRIES - 1: + time.sleep(2 ** attempt) + last_error = "5xx (%d)" % status + continue + raise RuntimeError( + "Notion Views API %d after %d retries: %s" % + (status, MAX_RETRIES, _short_error(resp)) + ) + + raise RuntimeError( + "Notion Views API unexpected status %d: %s" % + (status, _short_error(resp)) + ) + + raise RuntimeError("Notion Views API failed after retries: %s" % last_error) + + def retrieve_database(self, database_id): + return self._request("GET", "/databases/%s" % database_id) + + def get_primary_data_source_id(self, database_id): + database = self.retrieve_database(database_id) + data_sources = database.get("data_sources") or [] + if not data_sources: + raise RuntimeError("Database has no data_sources in 2025-09-03 API response") + return data_sources[0]["id"] + + def retrieve_data_source(self, data_source_id): + return self._request("GET", "/data_sources/%s" % data_source_id) + + def get_property_map(self, data_source_id): + data_source = self.retrieve_data_source(data_source_id) + properties = data_source.get("properties") or {} + prop_map = {} + for name, prop in properties.items(): + prop_map[name] = { + "id": unquote(prop.get("id") or name), + "type": prop.get("type") or _infer_property_type(prop), + } + return prop_map + + def list_views(self, database_id): + views = [] + cursor = None + while True: + params = {"database_id": database_id, "page_size": 100} + if cursor: + params["start_cursor"] = cursor + data = self._request("GET", "/views?%s" % urlencode(params)) + for ref in data.get("results", []): + view_id = ref.get("id") + if view_id: + views.append(self.retrieve_view(view_id)) + if not data.get("has_more"): + return views + cursor = data.get("next_cursor") + + def retrieve_view(self, view_id): + return self._request("GET", "/views/%s" % view_id) + + def create_view(self, payload): + return self._request("POST", "/views", payload) + + +class CoreViewsEnsurer: + """Create or verify the 5 permanent core views for the working diary DB.""" + + def __init__(self, client): + self.client = client + + def ensure(self, database_id, today, dry_run=False): + result = EnsureViewsResult() + data_source_id = self.client.get_primary_data_source_id(database_id) + prop_map = self.client.get_property_map(data_source_id) + missing = [name for name in REQUIRED_PROPERTIES if name not in prop_map] + if missing: + if dry_run and set(missing).issubset(set(SCHEMA_EXTENSION_PROPERTIES)): + result.planned.extend(CORE_VIEW_NAMES) + result.warnings.append( + "schema %s would add missing properties: %s" % + ("v5", ", ".join(missing)) + ) + return result + result.failed.append(ViewFailure( + "schema", + "missing required properties: %s" % ", ".join(missing), + )) + return result + + specs = _build_core_view_specs(database_id, data_source_id, prop_map, today) + existing_by_name = { + view.get("name"): view + for view in self.client.list_views(database_id) + if view.get("name") + } + + for spec in specs: + existing = existing_by_name.get(spec["name"]) + if existing: + reasons = _verify_view(existing, spec, prop_map, today) + if reasons: + result.conflicts.append(ViewConflict(spec["name"], "; ".join(reasons))) + else: + result.verified.append(spec["name"]) + continue + + if dry_run: + result.planned.append(spec["name"]) + continue + + self._create_view(spec, result) + + return result + + def _create_view(self, spec, result): + payload = _payload_for_spec(spec) + try: + self.client.create_view(payload) + result.created.append(spec["name"]) + return + except NotionBadRequest as e: + if spec.get("subtasks"): + fallback = _payload_for_spec(spec, include_subtasks=False) + try: + self.client.create_view(fallback) + result.created.append(spec["name"]) + result.warnings.append( + "%s subtasks not enabled: base table fallback created" % + spec["name"] + ) + return + except Exception as fallback_error: + result.failed.append(ViewFailure(spec["name"], str(fallback_error))) + return + if spec.get("relative_today"): + fallback_spec = dict(spec) + fallback_spec["filter"] = _fixed_today_filter(spec["today"]) + fallback_spec["relative_today"] = False + fallback = _payload_for_spec(fallback_spec) + try: + self.client.create_view(fallback) + result.created.append(spec["name"]) + result.warnings.append( + "%s relative today filter failed: fixed date fallback created" % + spec["name"] + ) + return + except Exception as fallback_error: + result.failed.append(ViewFailure(spec["name"], str(fallback_error))) + return + result.failed.append(ViewFailure(spec["name"], str(e))) + except Exception as e: + result.failed.append(ViewFailure(spec["name"], str(e))) + + +def _build_core_view_specs(database_id, data_source_id, prop_map, today): + return [ + _view_spec( + "작업 계층", + database_id, + data_source_id, + prop_map, + visible=[ + "Name", "Status", "Project", "Purpose", "Task Group", + "Parent Task", "Depends On", "Work Period", "Date", + ], + sorts=[_date_desc_sort()], + subtasks={ + "property_id": _prop_id(prop_map, "Parent Task"), + "display_mode": "show", + "filter_scope": "parents_and_subitems", + "toggle_column_id": _prop_id(prop_map, "Name"), + }, + ), + _view_spec( + "오늘 작업", + database_id, + data_source_id, + prop_map, + visible=[ + "Name", "Status", "Project", "Purpose", "Task Group", + "Work Period", "Parent Task", "Depends On", + ], + filter_body=_relative_today_filter(), + sorts=[_date_desc_sort()], + relative_today=True, + today=today, + ), + _view_spec( + "상태별", + database_id, + data_source_id, + prop_map, + visible=["Name", "Project", "Purpose", "Task Group", "Parent Task", "Work Period", "Date"], + group_by=_group_by(prop_map, "Status"), + ), + _view_spec( + "목적별", + database_id, + data_source_id, + prop_map, + visible=["Name", "Status", "Project", "Task Group", "Work Period", "Date"], + group_by=_group_by(prop_map, "Purpose"), + ), + _view_spec( + "프로젝트별", + database_id, + data_source_id, + prop_map, + visible=["Name", "Status", "Purpose", "Task Group", "Parent Task", "Work Period", "Date"], + group_by=_group_by(prop_map, "Project"), + ), + ] + + +def _view_spec( + name, + database_id, + data_source_id, + prop_map, + visible, + filter_body=None, + sorts=None, + group_by=None, + subtasks=None, + relative_today=False, + today=None, +): + return { + "name": name, + "database_id": database_id, + "data_source_id": data_source_id, + "visible": visible, + "properties": _property_config(prop_map, visible), + "filter": filter_body, + "sorts": sorts or [], + "group_by": group_by, + "subtasks": subtasks, + "relative_today": relative_today, + "today": today, + } + + +def _payload_for_spec(spec, include_subtasks=True): + configuration = { + "type": "table", + "properties": spec["properties"], + "wrap_cells": False, + "frozen_column_index": 1, + "show_vertical_lines": True, + } + if spec.get("group_by"): + configuration["group_by"] = spec["group_by"] + if include_subtasks and spec.get("subtasks"): + configuration["subtasks"] = spec["subtasks"] + + payload = { + "database_id": spec["database_id"], + "data_source_id": spec["data_source_id"], + "name": spec["name"], + "type": "table", + "configuration": configuration, + } + if spec.get("filter"): + payload["filter"] = spec["filter"] + if spec.get("sorts"): + payload["sorts"] = spec["sorts"] + return payload + + +def _property_config(prop_map, visible): + visible_set = set(visible) + ordered = [] + for name in visible: + ordered.append({"property_id": _prop_id(prop_map, name), "visible": True}) + for name in HIDDEN_PROPERTIES: + ordered.append({"property_id": _prop_id(prop_map, name), "visible": False}) + for name in ("Files", "Commits", "Lines", "Categories", "Branch"): + if name in prop_map and name not in visible_set: + ordered.append({"property_id": _prop_id(prop_map, name), "visible": False}) + return ordered + + +def _prop_id(prop_map, name): + return prop_map[name]["id"] + + +def _prop_type(prop_map, name): + return prop_map[name]["type"] + + +def _group_by(prop_map, name): + body = { + "type": _prop_type(prop_map, name), + "property_id": _prop_id(prop_map, name), + "sort": {"type": "manual"}, + "hide_empty_groups": False, + } + if body["type"] == "status": + body["group_by"] = "group" + if body["type"] == "date": + body["group_by"] = "day" + return body + + +def _date_desc_sort(): + return {"property": "Date", "direction": "descending"} + + +def _relative_today_filter(): + return {"property": "Date", "date": {"equals": "today"}} + + +def _fixed_today_filter(today): + return {"property": "Date", "date": {"equals": today}} + + +def _verify_view(view, spec, prop_map, today): + reasons = [] + if view.get("type") != "table": + reasons.append("view type is not table") + + visible_ids, visible_names, explicitly_visible_ids = _visible_properties(view, prop_map) + for name in spec["visible"]: + if name == "Name": + continue + if _prop_id(prop_map, name) not in visible_ids and name not in visible_names: + reasons.append("missing visible property: %s" % name) + + for name in HIDDEN_PROPERTIES: + prop_id = _prop_id(prop_map, name) + if prop_id in explicitly_visible_ids or name in visible_names: + reasons.append("hidden property is visible: %s" % name) + + if spec["name"] == "오늘 작업": + if not _has_today_filter(view.get("filter"), prop_map, today): + reasons.append("missing Date=today filter") + if not _has_date_desc_sort(view.get("sorts"), prop_map): + reasons.append("missing Date descending sort") + + if spec.get("group_by"): + if not _has_group_by(view, spec["group_by"]): + reasons.append("missing %s group_by" % _group_name(spec["name"])) + + return reasons + + +def _visible_properties(view, prop_map): + config = view.get("configuration") or {} + visible_ids = set() + visible_names = set() + explicitly_visible_ids = set() + for entry in config.get("properties") or []: + prop = entry.get("property_id") + if not prop: + continue + is_visible = entry.get("visible") is not False + if is_visible: + visible_ids.add(prop) + visible_names.add(prop) + explicitly_visible_ids.add(prop) + return visible_ids, visible_names, explicitly_visible_ids + + +def _has_today_filter(filter_body, prop_map, today): + date_names = {"Date", _prop_id(prop_map, "Date")} + for node in _walk(filter_body): + if not isinstance(node, dict): + continue + if (node.get("property") or node.get("property_id")) not in date_names: + continue + date_filter = node.get("date") or {} + equals = date_filter.get("equals") + if equals == "today" or equals == today: + return True + return False + + +def _has_date_desc_sort(sorts, prop_map): + date_keys = {"Date", _prop_id(prop_map, "Date")} + for sort in sorts or []: + prop = sort.get("property") or sort.get("property_id") + if prop in date_keys and sort.get("direction") == "descending": + return True + return False + + +def _has_group_by(view, required): + config = view.get("configuration") or {} + group_by = config.get("group_by") or {} + return ( + group_by.get("property_id") == required.get("property_id") + and group_by.get("type") == required.get("type") + ) + + +def _group_name(view_name): + if view_name == "상태별": + return "Status" + if view_name == "목적별": + return "Purpose" + if view_name == "프로젝트별": + return "Project" + return "required" + + +def _walk(value): + if isinstance(value, dict): + yield value + for child in value.values(): + for item in _walk(child): + yield item + elif isinstance(value, list): + for child in value: + for item in _walk(child): + yield item + + +def _infer_property_type(prop): + for key in ( + "title", "rich_text", "number", "select", "multi_select", "date", + "relation", "status", "checkbox", + ): + if key in prop: + return key + return "unknown" + + +def _short_error(resp): + try: + data = resp.json() + return data.get("message") or data.get("code") or resp.text[:200] + except Exception: + return resp.text[:200] diff --git a/tests/test_cli.py b/tests/test_cli.py index 29d3853..2674fe3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -499,6 +499,25 @@ def test_main_dispatches_dashboard(self, mock_cmd, capsys): main() mock_cmd.assert_called_once() + @patch("claude_diary.cli.cmd_notion_ensure") + def test_main_dispatches_diary_notion_ensure(self, mock_cmd, capsys): + with patch("sys.argv", ["claude-diary", "diary-notion", "ensure", "--year", "2026", "--dry-run"]): + main() + mock_cmd.assert_called_once() + args = mock_cmd.call_args.args[0] + assert args.action == "ensure" + assert args.year == 2026 + assert args.dry_run is True + + @patch("claude_diary.cli.cmd_notion_ensure") + def test_main_keeps_notion_ensure_alias(self, mock_cmd, capsys): + with patch("sys.argv", ["claude-diary", "notion", "ensure", "--dry-run"]): + main() + mock_cmd.assert_called_once() + args = mock_cmd.call_args.args[0] + assert args.action == "ensure" + assert args.dry_run is True + # ── cmd_trace tests ── diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index c047195..4c82534 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -26,7 +26,7 @@ def test_codex_skills_exist_and_cover_diary_workflows(): assert "working-diary write --input" in diary_text assert "claude-diary write --input" in diary_text - assert "working-diary notion push" in notion_text + assert "working-diary diary-notion push" in notion_text assert '"purpose": "Feature"' in notion_text assert '"summary_hints": ["..."]' in notion_text assert '"key_changes": ["..."]' in notion_text @@ -37,6 +37,7 @@ def test_codex_skills_exist_and_cover_diary_workflows(): assert '"verification": ["..."]' in notion_text assert '"risks": ["..."]' in notion_text assert '"support_needed": ["..."]' in notion_text + assert '"work_period": "2026-06-02"' in notion_text assert '"parent_index": null' in notion_text assert "Exclude full diffs" in notion_text assert "in Korean" in notion_text diff --git a/tests/test_notion_ensure.py b/tests/test_notion_ensure.py new file mode 100644 index 0000000..bddb245 --- /dev/null +++ b/tests/test_notion_ensure.py @@ -0,0 +1,96 @@ +"""Tests for `claude-diary diary-notion ensure` CLI.""" + +from argparse import Namespace +from unittest.mock import ANY, MagicMock, patch + +import pytest + +from claude_diary.cli.notion_ensure import cmd_notion_ensure +from claude_diary.exporters.notion_views import EnsureViewsResult, ViewConflict + + +def _config(): + return { + "timezone_offset": 9, + "exporters": { + "notion_hierarchical": { + "api_token": "token", + "root_page_id": "root_page", + } + }, + } + + +def _args(year=2026, dry_run=False): + return Namespace(year=year, dry_run=dry_run) + + +class TestCmdNotionEnsure: + def test_missing_credentials_exits(self): + with patch("claude_diary.cli.notion_ensure.load_config", return_value={}), \ + patch.dict("os.environ", {}, clear=True), \ + pytest.raises(SystemExit) as exc: + cmd_notion_ensure(_args()) + + assert exc.value.code == 1 + + def test_non_dry_run_ensures_schema_and_views(self, capsys): + exporter = MagicMock() + exporter.ensure_database.return_value = "db1" + result = EnsureViewsResult(created=["작업 계층"], verified=["오늘 작업"]) + ensurer = MagicMock() + ensurer.ensure.return_value = result + + with patch("claude_diary.cli.notion_ensure.load_config", return_value=_config()), \ + patch.dict("os.environ", {}, clear=True), \ + patch("claude_diary.cli.notion_ensure.NotionHierarchicalExporter", return_value=exporter), \ + patch("claude_diary.cli.notion_ensure.NotionViewsClient") as client_cls, \ + patch("claude_diary.cli.notion_ensure.CoreViewsEnsurer", return_value=ensurer): + cmd_notion_ensure(_args(year=2026)) + + exporter.load_cache.assert_called_once() + exporter.ensure_database.assert_called_once_with(2026, force_schema=True) + exporter.save_cache.assert_called_once() + client_cls.assert_called_once_with({"api_token": "token"}) + ensurer.ensure.assert_called_once_with("db1", ANY, dry_run=False) + captured = capsys.readouterr() + assert "Schema: v5 ensured" in captured.out + assert "+ 작업 계층" in captured.out + assert "= 오늘 작업 (verified)" in captured.out + + def test_dry_run_missing_database_prints_plan_without_writes(self, capsys): + exporter = MagicMock() + exporter.resolve_existing_database.return_value = None + + with patch("claude_diary.cli.notion_ensure.load_config", return_value=_config()), \ + patch.dict("os.environ", {}, clear=True), \ + patch("claude_diary.cli.notion_ensure.NotionHierarchicalExporter", return_value=exporter), \ + patch("claude_diary.cli.notion_ensure.CoreViewsEnsurer") as ensurer_cls: + cmd_notion_ensure(_args(year=2027, dry_run=True)) + + exporter.load_cache.assert_called_once() + exporter.resolve_existing_database.assert_called_once_with(2027) + exporter.ensure_database.assert_not_called() + exporter.save_cache.assert_not_called() + ensurer_cls.assert_not_called() + captured = capsys.readouterr() + assert "Database: missing" in captured.out + assert "+ create 5 core views" in captured.out + + def test_conflict_exits_1(self): + exporter = MagicMock() + exporter.ensure_database.return_value = "db1" + result = EnsureViewsResult(conflicts=[ + ViewConflict("오늘 작업", "missing Date=today filter"), + ]) + ensurer = MagicMock() + ensurer.ensure.return_value = result + + with patch("claude_diary.cli.notion_ensure.load_config", return_value=_config()), \ + patch.dict("os.environ", {}, clear=True), \ + patch("claude_diary.cli.notion_ensure.NotionHierarchicalExporter", return_value=exporter), \ + patch("claude_diary.cli.notion_ensure.CoreViewsEnsurer", return_value=ensurer), \ + pytest.raises(SystemExit) as exc: + cmd_notion_ensure(_args(year=2026)) + + assert exc.value.code == 1 diff --git a/tests/test_notion_hierarchical.py b/tests/test_notion_hierarchical.py index 135626f..a5026da 100644 --- a/tests/test_notion_hierarchical.py +++ b/tests/test_notion_hierarchical.py @@ -212,12 +212,14 @@ def test_creates_database_with_full_schema(self, tmp_path): exp._cache["years"]["2026"] = "year_page" # year already cached mock_req = MagicMock() - # Three calls: + # Four calls: # 1. GET /blocks/year_page (existence check from ensure_year_page cache hit) - # 2. POST /databases (create base schema) - # 3. PATCH /databases/{id} (Purpose + Status + Task Group + relation extensions) + # 2. GET /blocks/year_page/children (find an existing Entries DB) + # 3. POST /databases (create base schema) + # 4. PATCH /databases/{id} (Purpose + Status + Task Group + relation extensions) mock_req.request.side_effect = [ _make_response(200, {"id": "year_page"}), + _make_response(200, {"results": [], "has_more": False}), _make_response(200, {"id": "db_xyz"}), _make_response(200, {"id": "db_xyz"}), ] @@ -225,8 +227,8 @@ def test_creates_database_with_full_schema(self, tmp_path): db_id = exp.ensure_database(2026) assert db_id == "db_xyz" - # Second call: create POST (base schema only) - create_call = mock_req.request.call_args_list[1] + # Third call: create POST (base schema only) + create_call = mock_req.request.call_args_list[2] assert create_call.args[0] == "POST" create_body = create_call.kwargs["json"] assert create_body["parent"]["page_id"] == "year_page" @@ -243,19 +245,19 @@ def test_creates_database_with_full_schema(self, tmp_path): assert "Depends On" not in props assert "Parent Task" not in props - # Third call: PATCH to add Purpose + Status + Task Group + relation columns - patch_call = mock_req.request.call_args_list[2] + # Fourth call: PATCH to add Purpose + Status + Task Group + relation columns + patch_call = mock_req.request.call_args_list[3] assert patch_call.args[0] == "PATCH" assert patch_call.args[1].endswith("/databases/db_xyz") patch_body = patch_call.kwargs["json"] - for col in ["Purpose", "Status", "Task Group", "Depends On", "Parent Task"]: + for col in ["Purpose", "Status", "Task Group", "Depends On", "Parent Task", "Work Period"]: assert col in patch_body["properties"] relation = patch_body["properties"]["Depends On"]["relation"] assert relation["database_id"] == "db_xyz" # self-reference parent_relation = patch_body["properties"]["Parent Task"]["relation"] assert parent_relation["database_id"] == "db_xyz" # Cache flag recorded so future calls skip the PATCH - assert exp._cache["schema_v"]["db_xyz"] == "v4" + assert exp._cache["schema_v"]["db_xyz"] == "v5" def test_existing_database_gets_schema_extension(self, tmp_path): """Cache-hit database (older schema) gets current schema columns via PATCH.""" @@ -277,17 +279,48 @@ def test_existing_database_gets_schema_extension(self, tmp_path): # Second call: PATCH for schema extension on the existing DB patch_call = mock_req.request.call_args_list[1] assert patch_call.args[0] == "PATCH" - for col in ["Purpose", "Status", "Task Group", "Depends On", "Parent Task"]: + for col in ["Purpose", "Status", "Task Group", "Depends On", "Parent Task", "Work Period"]: assert col in patch_call.kwargs["json"]["properties"] - assert exp._cache["schema_v"]["old_db"] == "v4" + assert exp._cache["schema_v"]["old_db"] == "v5" + + def test_cache_miss_reuses_existing_entries_database(self, tmp_path): + """If cache is empty but Entries already exists, do not create a duplicate DB.""" + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["years"]["2026"] = "year_page" + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, {"id": "year_page"}), + _make_response(200, { + "results": [ + { + "id": "existing_db", + "type": "child_database", + "child_database": {"title": "Entries"}, + }, + ], + "has_more": False, + }), + _make_response(200, {"id": "existing_db"}), + ] + with _patch_requests(mock_req): + db_id = exp.ensure_database(2026) + + assert db_id == "existing_db" + assert exp._cache["databases"]["2026"] == "existing_db" + methods = [call_args.args[0] for call_args in mock_req.request.call_args_list] + assert "POST" not in methods + assert exp._cache["schema_v"]["existing_db"] == "v5" def test_schema_extension_skipped_when_already_flagged(self, tmp_path): - """Once schema_v records the DB at v4, no further PATCH is sent.""" + """Once schema_v records the DB at v5, no further PATCH is sent.""" exp = _make_exporter() with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): exp.load_cache() exp._cache["databases"]["2026"] = "db_known" - exp._cache["schema_v"]["db_known"] = "v4" + exp._cache["schema_v"]["db_known"] = "v5" mock_req = MagicMock() mock_req.request.return_value = _make_response(200, {"id": "db_known"}) @@ -299,8 +332,30 @@ def test_schema_extension_skipped_when_already_flagged(self, tmp_path): assert mock_req.request.call_count == 1 assert mock_req.request.call_args.args[0] == "GET" + def test_force_schema_patches_even_when_already_flagged(self, tmp_path): + """Explicit ensure can force a schema patch even when cache says v5.""" + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["databases"]["2026"] = "db_known" + exp._cache["schema_v"]["db_known"] = "v5" + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, {"id": "db_known"}), + _make_response(200, {"id": "db_known"}), + ] + with _patch_requests(mock_req): + db_id = exp.ensure_database(2026, force_schema=True) + + assert db_id == "db_known" + assert mock_req.request.call_args_list[1].args[0] == "PATCH" + patch_body = mock_req.request.call_args_list[1].kwargs["json"] + assert "Work Period" in patch_body["properties"] + assert "Parent Task" in patch_body["properties"] + def test_v3_database_gets_parent_task_upgrade(self, tmp_path): - """A DB already marked v3 still gets the v4 Parent Task schema patch.""" + """A DB already marked v3 still gets Parent Task and Work Period schema patches.""" exp = _make_exporter() with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): exp.load_cache() @@ -317,13 +372,13 @@ def test_v3_database_gets_parent_task_upgrade(self, tmp_path): assert db_id == "db_v3" patch_body = mock_req.request.call_args_list[1].kwargs["json"] - assert list(patch_body["properties"].keys()) == ["Parent Task"] + assert list(patch_body["properties"].keys()) == ["Parent Task", "Work Period"] relation = patch_body["properties"]["Parent Task"]["relation"] assert relation["database_id"] == "db_v3" - assert exp._cache["schema_v"]["db_v3"] == "v4" + assert exp._cache["schema_v"]["db_v3"] == "v5" def test_v2_database_gets_purpose_and_parent_task_upgrade(self, tmp_path): - """A DB already marked v2 still gets Purpose and Parent Task schema patches.""" + """A DB already marked v2 still gets Purpose, Parent Task, and Work Period patches.""" exp = _make_exporter() with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): exp.load_cache() @@ -342,7 +397,29 @@ def test_v2_database_gets_purpose_and_parent_task_upgrade(self, tmp_path): patch_body = mock_req.request.call_args_list[1].kwargs["json"] assert "Purpose" in patch_body["properties"] assert "Parent Task" in patch_body["properties"] - assert exp._cache["schema_v"]["db_v2"] == "v4" + assert "Work Period" in patch_body["properties"] + assert exp._cache["schema_v"]["db_v2"] == "v5" + + def test_v4_database_gets_work_period_upgrade(self, tmp_path): + """A DB already marked v4 still gets the v5 Work Period schema patch.""" + exp = _make_exporter() + with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): + exp.load_cache() + exp._cache["databases"]["2026"] = "db_v4" + exp._cache["schema_v"]["db_v4"] = "v4" + + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, {"id": "db_v4"}), + _make_response(200, {"id": "db_v4"}), + ] + with _patch_requests(mock_req): + db_id = exp.ensure_database(2026) + + assert db_id == "db_v4" + patch_body = mock_req.request.call_args_list[1].kwargs["json"] + assert list(patch_body["properties"].keys()) == ["Work Period"] + assert exp._cache["schema_v"]["db_v4"] == "v5" class TestFindExistingRow: diff --git a/tests/test_notion_init.py b/tests/test_notion_init.py index 0867835..8b7a234 100644 --- a/tests/test_notion_init.py +++ b/tests/test_notion_init.py @@ -1,4 +1,4 @@ -"""Tests for `claude-diary notion init` interactive setup.""" +"""Tests for `claude-diary diary-notion init` interactive setup.""" import json from unittest.mock import patch, MagicMock diff --git a/tests/test_notion_push.py b/tests/test_notion_push.py index 0cb6747..01d1622 100644 --- a/tests/test_notion_push.py +++ b/tests/test_notion_push.py @@ -1,4 +1,4 @@ -"""Tests for `claude-diary notion push` CLI.""" +"""Tests for `claude-diary diary-notion push` CLI.""" import json import os @@ -13,6 +13,7 @@ _build_properties, _gather_git_info, _normalize_purpose, + _normalize_work_period, _read_json, ) from claude_diary.exporters.notion_hierarchical import ( @@ -107,6 +108,7 @@ def test_all_columns_present(self): props = _build_properties(task, "2026-05-26", "feat/x", git_info, "sess1", 0) assert props["Name"]["title"][0]["text"]["content"] == "DB 결정" assert props["Date"]["date"]["start"] == "2026-05-26" + assert props["Work Period"]["date"]["start"] == "2026-05-26" assert props["Project"]["select"]["name"] == "diary" assert props["Purpose"]["select"]["name"] == "Feature" assert props["Branch"]["select"]["name"] == "feat/x" @@ -169,6 +171,31 @@ def test_depends_on_not_in_properties(self): assert "Depends On" not in props assert "Parent Task" not in props + def test_work_period_range_included(self): + task = dict(self._base_task(), work_period={"start": "2026-05-25", "end": "2026-05-26"}) + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert props["Work Period"]["date"] == { + "start": "2026-05-25", + "end": "2026-05-26", + } + + +class TestNormalizeWorkPeriod: + def test_missing_falls_back_to_date(self): + assert _normalize_work_period(None, "2026-05-26") == {"start": "2026-05-26"} + + def test_string_date(self): + assert _normalize_work_period("2026-05-25", "2026-05-26") == {"start": "2026-05-25"} + + def test_string_range(self): + assert _normalize_work_period("2026-05-25..2026-05-26", "2026-05-26") == { + "start": "2026-05-25", + "end": "2026-05-26", + } + + def test_invalid_falls_back(self): + assert _normalize_work_period("not-a-date", "2026-05-26") == {"start": "2026-05-26"} + class TestDependsOnWiring: def _make_args(self, input_path, force=False): diff --git a/tests/test_notion_views.py b/tests/test_notion_views.py new file mode 100644 index 0000000..2caa2b0 --- /dev/null +++ b/tests/test_notion_views.py @@ -0,0 +1,254 @@ +"""Tests for Notion Views API client and core view automation.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from claude_diary.exporters.notion_hierarchical import NotionBadRequest +from claude_diary.exporters.notion_views import ( + CORE_VIEW_NAMES, + NOTION_VIEWS_API_VERSION, + CoreViewsEnsurer, + NotionViewsClient, + _build_core_view_specs, + _payload_for_spec, +) + + +def _make_response(status, json_body=None, headers=None): + resp = MagicMock() + resp.status_code = status + resp.json.return_value = json_body or {} + resp.headers = headers or {} + resp.text = "" + return resp + + +def _patch_requests(mock_requests): + return patch.dict("sys.modules", {"requests": mock_requests}) + + +def _prop_map(): + return { + "Name": {"id": "title", "type": "title"}, + "Date": {"id": "date_id", "type": "date"}, + "Work Period": {"id": "work_period_id", "type": "date"}, + "Project": {"id": "project_id", "type": "select"}, + "Purpose": {"id": "purpose_id", "type": "select"}, + "Status": {"id": "status_id", "type": "select"}, + "Task Group": {"id": "task_group_id", "type": "select"}, + "Parent Task": {"id": "parent_id", "type": "relation"}, + "Depends On": {"id": "depends_id", "type": "relation"}, + "Session ID": {"id": "session_id", "type": "rich_text"}, + "Task Index": {"id": "task_index_id", "type": "number"}, + "Files": {"id": "files_id", "type": "number"}, + "Commits": {"id": "commits_id", "type": "number"}, + "Lines": {"id": "lines_id", "type": "number"}, + "Categories": {"id": "categories_id", "type": "multi_select"}, + "Branch": {"id": "branch_id", "type": "select"}, + } + + +def _matching_views(database_id="db1", data_source_id="ds1", today="2026-06-02"): + specs = _build_core_view_specs(database_id, data_source_id, _prop_map(), today) + views = [] + for spec in specs: + payload = _payload_for_spec(spec) + views.append({ + "id": "%s_id" % spec["name"], + "name": spec["name"], + "type": payload["type"], + "filter": payload.get("filter"), + "sorts": payload.get("sorts"), + "configuration": payload["configuration"], + }) + return views + + +class FakeViewsClient: + def __init__(self, views=None, prop_map=None): + self.views = views if views is not None else [] + self.prop_map = prop_map if prop_map is not None else _prop_map() + self.created_payloads = [] + + def get_primary_data_source_id(self, database_id): + return "ds1" + + def get_property_map(self, data_source_id): + return self.prop_map + + def list_views(self, database_id): + return self.views + + def create_view(self, payload): + self.created_payloads.append(payload) + return {"id": "created_%d" % len(self.created_payloads)} + + +class TestNotionViewsClient: + def test_uses_views_api_version_header(self): + mock_req = MagicMock() + mock_req.request.return_value = _make_response(200, {"id": "view1"}) + with _patch_requests(mock_req): + client = NotionViewsClient({"api_token": "secret_xxx"}) + client.retrieve_view("view1") + + headers = mock_req.request.call_args.kwargs["headers"] + assert headers["Notion-Version"] == NOTION_VIEWS_API_VERSION + + def test_get_primary_data_source_id_from_database(self): + client = NotionViewsClient({"api_token": "secret_xxx"}) + with patch.object(client, "retrieve_database", return_value={ + "data_sources": [{"id": "ds1", "name": "Entries"}], + }): + assert client.get_primary_data_source_id("db1") == "ds1" + + def test_property_map_uses_data_source_property_ids(self): + client = NotionViewsClient({"api_token": "secret_xxx"}) + with patch.object(client, "retrieve_data_source", return_value={ + "properties": { + "Name": {"id": "title", "type": "title"}, + "Status": {"id": "abc", "type": "select"}, + "Date": {"id": "yo%7DQ", "type": "date"}, + } + }): + prop_map = client.get_property_map("ds1") + assert prop_map["Name"] == {"id": "title", "type": "title"} + assert prop_map["Status"] == {"id": "abc", "type": "select"} + assert prop_map["Date"] == {"id": "yo}Q", "type": "date"} + + +class TestCoreViewsEnsurer: + def test_creates_all_missing_core_views(self): + client = FakeViewsClient() + result = CoreViewsEnsurer(client).ensure("db1", "2026-06-02") + + assert result.created == list(CORE_VIEW_NAMES) + assert result.ok() + assert len(client.created_payloads) == 5 + + by_name = {payload["name"]: payload for payload in client.created_payloads} + hierarchy = by_name["작업 계층"] + hierarchy_config = hierarchy["configuration"] + assert hierarchy_config["subtasks"]["property_id"] == "parent_id" + assert _visible(hierarchy, "parent_id") is True + assert _visible(hierarchy, "work_period_id") is True + assert _visible(hierarchy, "session_id") is False + + today = by_name["오늘 작업"] + assert today["filter"] == {"property": "Date", "date": {"equals": "today"}} + assert today["sorts"] == [{"property": "Date", "direction": "descending"}] + + assert by_name["상태별"]["configuration"]["group_by"]["property_id"] == "status_id" + assert by_name["목적별"]["configuration"]["group_by"]["property_id"] == "purpose_id" + assert by_name["프로젝트별"]["configuration"]["group_by"]["property_id"] == "project_id" + + def test_verifies_existing_matching_views(self): + client = FakeViewsClient(views=_matching_views()) + result = CoreViewsEnsurer(client).ensure("db1", "2026-06-02") + + assert result.verified == list(CORE_VIEW_NAMES) + assert result.ok() + assert client.created_payloads == [] + + def test_dry_run_plans_missing_views_without_create(self): + client = FakeViewsClient() + result = CoreViewsEnsurer(client).ensure("db1", "2026-06-02", dry_run=True) + + assert result.planned == list(CORE_VIEW_NAMES) + assert result.ok() + assert client.created_payloads == [] + + def test_existing_view_with_required_mismatch_is_conflict(self): + views = _matching_views() + for view in views: + if view["name"] == "오늘 작업": + view["filter"] = None + + client = FakeViewsClient(views=views) + result = CoreViewsEnsurer(client).ensure("db1", "2026-06-02") + + assert not result.ok() + assert len(result.conflicts) == 1 + assert result.conflicts[0].name == "오늘 작업" + assert "Date=today" in result.conflicts[0].reason + + def test_missing_required_property_fails_before_create(self): + prop_map = dict(_prop_map()) + prop_map.pop("Work Period") + client = FakeViewsClient(prop_map=prop_map) + + result = CoreViewsEnsurer(client).ensure("db1", "2026-06-02") + + assert not result.ok() + assert result.failed[0].name == "schema" + assert "Work Period" in result.failed[0].reason + assert client.created_payloads == [] + + def test_dry_run_plans_schema_extension_when_work_period_missing(self): + prop_map = dict(_prop_map()) + prop_map.pop("Work Period") + client = FakeViewsClient(prop_map=prop_map) + + result = CoreViewsEnsurer(client).ensure("db1", "2026-06-02", dry_run=True) + + assert result.ok() + assert result.planned == list(CORE_VIEW_NAMES) + assert any("Work Period" in w for w in result.warnings) + assert client.created_payloads == [] + + def test_today_relative_filter_falls_back_to_fixed_date(self): + views = [v for v in _matching_views() if v["name"] != "오늘 작업"] + client = FailingCreateClient( + views=views, + fail_when=lambda payload: ( + payload["name"] == "오늘 작업" + and payload.get("filter", {}).get("date", {}).get("equals") == "today" + ), + ) + + result = CoreViewsEnsurer(client).ensure("db1", "2026-06-02") + + assert result.ok() + assert result.created == ["오늘 작업"] + assert client.created_payloads[-1]["filter"] == { + "property": "Date", + "date": {"equals": "2026-06-02"}, + } + assert any("relative today filter failed" in w for w in result.warnings) + + def test_subtasks_failure_falls_back_to_base_table(self): + views = [v for v in _matching_views() if v["name"] != "작업 계층"] + client = FailingCreateClient( + views=views, + fail_when=lambda payload: ( + payload["name"] == "작업 계층" + and "subtasks" in payload.get("configuration", {}) + ), + ) + + result = CoreViewsEnsurer(client).ensure("db1", "2026-06-02") + + assert result.ok() + assert result.created == ["작업 계층"] + assert "subtasks" not in client.created_payloads[-1]["configuration"] + assert any("subtasks not enabled" in w for w in result.warnings) + + +class FailingCreateClient(FakeViewsClient): + def __init__(self, views, fail_when): + super().__init__(views=views) + self.fail_when = fail_when + + def create_view(self, payload): + self.created_payloads.append(payload) + if self.fail_when(payload): + raise NotionBadRequest("rejected") + return {"id": "created_%d" % len(self.created_payloads)} + + +def _visible(payload, property_id): + for entry in payload["configuration"]["properties"]: + if entry["property_id"] == property_id: + return entry["visible"] + return None diff --git a/tests/test_setup.py b/tests/test_setup.py index a91d4d1..b0a2698 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -69,6 +69,7 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): "risks", "next_steps", "support_needed", + "work_period", "parent_index", "depends_on_indices", ] @@ -154,7 +155,7 @@ def test_cmd_install_with_force_refreshes_diary_notion(self, tmp_path): commands_dir = tmp_path / ".claude" / "commands" commands_dir.mkdir(parents=True) (commands_dir / "diary-notion.md").write_text( - "---\nold instructions claude-diary notion push\n", encoding="utf-8" + "---\nold instructions claude-diary diary-notion push\n", encoding="utf-8" ) args = MagicMock() args.force = True @@ -177,7 +178,7 @@ def test_installs_both_commands(self, tmp_path): assert diary.exists() assert diary_notion.exists() assert "claude-diary write" in diary.read_text(encoding="utf-8") - assert "claude-diary notion push" in diary_notion.read_text(encoding="utf-8") + assert "claude-diary diary-notion push" in diary_notion.read_text(encoding="utf-8") def test_upgrade_path_keeps_existing_diary(self, tmp_path): """If user already has /diary from old install, /diary-notion still gets added.""" @@ -205,7 +206,7 @@ def test_installs_codex_skills(self, tmp_path): assert diary.exists() assert notion.exists() assert "working-diary write --input" in diary.read_text(encoding="utf-8") - assert "working-diary notion push" in notion.read_text(encoding="utf-8") + assert "working-diary diary-notion push" in notion.read_text(encoding="utf-8") class TestUninstallAll: From 371d4cf22182b2b0c60f9c1ad3ddee5c75201a87 Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 2 Jun 2026 14:20:11 +0900 Subject: [PATCH 20/30] fix: fallback diary notion project to cwd --- README.en.md | 2 +- README.md | 2 +- .../diary-notion-hierarchical.design.md | 48 +++++++++++++----- docs/04-report/diary-notion-phase-2/README.md | 50 +++++++++++++++++++ skills/diary-notion/SKILL.md | 3 +- src/claude_diary/cli/notion_push.py | 25 ++++++++-- src/claude_diary/cli/setup.py | 5 +- tests/test_notion_push.py | 50 +++++++++++++++++++ 8 files changed, 165 insertions(+), 20 deletions(-) diff --git a/README.en.md b/README.en.md index 3c60606..1be7f05 100644 --- a/README.en.md +++ b/README.en.md @@ -133,7 +133,7 @@ Push the current session to a hierarchical Notion database as task-sized rows. U └── ... ``` -Rows include filterable/groupable/relational columns for `Project`, `Purpose`, `Task Group`, `Parent Task`, `Depends On`, `Branch`, `Status`, `Work Period`, and `Categories`. Run `working-diary diary-notion ensure` to create or verify the 5 core Notion views. +Rows include filterable/groupable/relational columns for `Project`, `Purpose`, `Task Group`, `Parent Task`, `Depends On`, `Branch`, `Status`, `Work Period`, and `Categories`. `Project` is the command cwd folder name; if a task JSON omits it or writes `unknown`, the CLI falls back to the cwd folder. Run `working-diary diary-notion ensure` to create or verify the 5 core Notion views. `Parent Task` represents containment, while `Depends On` represents prerequisite order. Each Notion page body stays compact with callouts/checklists, and developer evidence such as code changes, files, commands, Git, and original prompts is hidden in appendix toggles. Code changes are high-signal summaries, not full diffs; include only behavior, schema, CLI, user workflow, or verification-scope changes. Titles and narrative body content are written in Korean. File paths, commands, branches, commit hashes, code identifiers, and `Purpose`/`Status` enum values remain literal or English. diff --git a/README.md b/README.md index c29fa12..27cd820 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ $diary-notion # Codex 세션 안에서 | Name | title | Claude가 뽑은 task 제목 (명사구) | | Date | date | | | Work Period | date | 실제 작업 기간. 프로젝트/작업 그룹 기간 계산 재료 | -| Project | select | cwd 폴더명. group/filter용 | +| Project | select | cwd 폴더명. group/filter용. task JSON에서 누락되거나 `unknown`이면 CLI가 명령 실행 cwd로 보정 | | Purpose | select | Feature/Bugfix/Refactor/Docs/Test/Infra/Planning/Research/Review/Release/Support/Maintenance/General | | Branch | select | task별 branch (group/filter용) | | Status | select | Discussion/Design/Implementation/Testing/Deployed | diff --git a/docs/02-design/features/diary-notion-hierarchical.design.md b/docs/02-design/features/diary-notion-hierarchical.design.md index 8b40ab4..2400e72 100644 --- a/docs/02-design/features/diary-notion-hierarchical.design.md +++ b/docs/02-design/features/diary-notion-hierarchical.design.md @@ -78,7 +78,7 @@ |------|------|------|---------|------| | Name | title | ✅ | "DB 컬럼 스키마 의논" | 에이전트가 뽑은 task 제목 | | Date | date | ✅ | 2026-05-26 | 정렬/필터/캘린더 뷰 | -| Project | select | ✅ | claude-diary | group/filter | +| Project | select | ✅ | claude-diary | group/filter. task JSON 누락/unknown 시 CLI가 cwd 폴더명으로 보정 | | Purpose | select | ✅ | Feature | 목적별 group/filter | | Branch | select | ✅ | feat/diary-notion | group/filter. CLI가 자동 채움 | | Status | select | ✅ | Implementation | 5단계: Discussion/Design/Implementation/Testing/Deployed | @@ -123,36 +123,59 @@ ### 3.2 Layer 2 — Page Body (행 클릭 시 보이는 markdown) ```markdown -[callout] body_intro - 에이전트가 작성한 1~3문장 의미 요약 +[callout] body_intro - 핵심 결과 1개. callout은 여기와 경고성 리스크에만 제한한다. -## 요약 -[callout] 결과/의미 요약 +## 결과 +- [x] 최종 결과 +- [x] 검증 완료 항목 +- [x] 커밋/푸시/배포 등 완료 상태 ## 작업 한눈에 -[callout] 배경/범위/접근/결과 +| 항목 | 내용 | +| --- | --- | +| 배경 | 왜 시작했는가 | +| 범위 | 무엇을 바꿨는가 | +| 접근 | 어떻게 풀었는가 | +| 결과 | 어떤 상태가 되었는가 | ## 영향 -[callout] 사용자/운영/제품/개발 품질 영향 +- 사용자/운영/제품/개발 품질 영향 -## 검증 및 상태 -[checked todo] 실행한 검증 -[callout] 남은 리스크 +## 검증 +- [x] 최종 검증 결과 -## 다음 액션 -[unchecked todo] 후속 작업 +## 리스크 / 다음 액션 +[callout] 필요한 경우에만 남은 리스크 +- [ ] 후속 작업 ## 부록 [toggle] 개발 근거: 주요 변경, 주요 코드 변경, 파일, 명령어, Git, 이슈 [toggle] 원문 요청: user_prompts 원문 ``` +문제 해결형 작업은 `결과` 섹션을 다음 형태로 우선 렌더링할 수 있다. + +```markdown +## 결과 + +- 문제: 정상 생성된 view가 required property 누락 conflict로 오인됨 +- 원인: data source schema와 view retrieve 응답의 property id encoding 기준이 다름 +- 조치: property id를 decode해 비교 기준을 통일 +- 결과: core view 5개 verified +``` + **조립**: 에이전트가 만든 `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `risks`, `next_steps`, `support_needed` + CLI가 코드/파일/명령/Git raw 데이터를 접힌 부록(toggle)으로 조립한다. 코드 변경은 full diff가 아니라 주요 변경만 기록한다. **언어 정책**: `title`과 설명형 본문 필드(`body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `risks`, `next_steps`, `support_needed`)는 한국어로 작성한다. 파일 경로, 명령어, branch, commit hash, 코드 식별자, 함수/클래스명, `Purpose`/`Status` enum 값은 원문 또는 영어 값을 유지한다. **본문 보고 원칙**: - DB relation이 구조를 담당하고, page body는 짧은 상태와 근거를 담당한다. -- `요약`, `작업 한눈에`, `영향`, `검증 및 상태`, `다음 액션`, `부록` 순서로 배치한다. +- `결과`, `작업 한눈에`, `영향`, `검증`, `리스크 / 다음 액션`, `부록` 순서로 배치한다. +- callout을 과하게 쓰지 않는다. 최상단 핵심 요약 1개와 경고성 리스크 정도로 제한한다. +- `작업 한눈에`는 callout 여러 개가 아니라 표로 렌더링한다. +- 검증은 최종 상태를 우선 노출하고, 중간 실행 결과는 부록으로 내린다. +- 사용자-facing 명령은 `$diary-notion` 또는 `working-diary diary-notion ...` 기준으로 노출한다. +- 과거 명령이나 내부 명령은 발생 근거가 필요할 때만 부록에 둔다. - 주요 코드 변경과 파일/명령/Git/오류는 핵심 메시지가 아니라 근거이므로 접힌 `부록`에 둔다. - Notion API child block 100개 제한을 넘지 않도록 렌더링 한도를 보수적으로 둔다. @@ -210,6 +233,7 @@ ### 4.2 CLI의 책임 - `commit_hashes`로 git 메타 수집 (message, lines, branch) — `git_info.py` 재사용 +- task별 Project 자동 보정 (task `project`가 없거나 `unknown`/placeholder이면 명령 실행 cwd 폴더명 사용) - task별 Branch 자동 결정 (commit 있으면 첫 commit의 branch, 없으면 HEAD branch) - Layer 2 body 조립 (`body_intro` + callout/checklist/toggle 부록) — `formatter.py` 확장 - 연도 페이지/DB 자동 생성 (없으면) diff --git a/docs/04-report/diary-notion-phase-2/README.md b/docs/04-report/diary-notion-phase-2/README.md index 4c6745c..8fd19df 100644 --- a/docs/04-report/diary-notion-phase-2/README.md +++ b/docs/04-report/diary-notion-phase-2/README.md @@ -194,6 +194,56 @@ NotionViewsClient - `tests/test_setup.py` - `tests/test_codex_plugin.py` +### Step 8. 생성 본문 품질 피드백 + +실제 `$diary-notion`으로 생성된 Notion page body를 검토한 결과, 정보량은 충분하지만 읽기 UX가 아직 최종 형태에 미치지 못한다. + +확인된 문제: + +- `body_intro`, `summary_hints`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `risks`가 대부분 callout으로 렌더링되어 `