From 7feb1d95bcabdade80fcc71e613276b8b2de51be Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 17 Mar 2026 19:10:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Sprint=20C-1=20=E2=80=94=20?= =?UTF-8?q?=ED=8C=80=20=EB=B3=B4=EC=95=88=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/team_security.py: - 경로 마스킹: glob 패턴으로 민감 경로 자동 [MASKED] - 콘텐츠 필터: 키워드 기반 redact/skip 모드 - 세션 opt-out: CLAUDE_DIARY_SKIP 환경변수 + skip_projects - 접근 제어: member/lead/admin 3단계 역할 + 권한 필터링 core.py: - opt-out 체크 (파이프라인 최초) - 경로 마스킹 + 콘텐츠 필터 (시크릿 스캔 후 적용) cli.py: - delete --last / --session 명령어 (세션 삭제) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/claude_diary/cli.py | 73 +++++++++ src/claude_diary/core.py | 25 +++ src/claude_diary/lib/team_security.py | 210 ++++++++++++++++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 src/claude_diary/lib/team_security.py diff --git a/src/claude_diary/cli.py b/src/claude_diary/cli.py index 418bbe5..1a0d88f 100644 --- a/src/claude_diary/cli.py +++ b/src/claude_diary/cli.py @@ -74,6 +74,11 @@ def main(): p_audit.add_argument("--verify", action="store_true", help="Verify source code checksum") p_audit.add_argument("-n", type=int, default=10, help="Number of entries (default: 10)") + # delete + p_delete = sub.add_parser("delete", help="Delete a diary session entry") + p_delete.add_argument("--last", action="store_true", help="Delete the last session entry") + p_delete.add_argument("--session", help="Delete by session ID prefix") + # dashboard p_dashboard = sub.add_parser("dashboard", help="Generate HTML dashboard") p_dashboard.add_argument("--serve", action="store_true", help="Start local server") @@ -97,6 +102,7 @@ def main(): "migrate": cmd_migrate, "reindex": cmd_reindex, "audit": cmd_audit, + "delete": cmd_delete, "dashboard": cmd_dashboard, } @@ -645,6 +651,73 @@ def cmd_audit(args): print(line) +# ── Delete ── + +def cmd_delete(args): + config = load_config() + diary_dir = os.path.expanduser(config["diary_dir"]) + tz_offset = config.get("timezone_offset", 9) + local_tz = timezone(timedelta(hours=tz_offset)) + + if args.last: + # Find today's file and remove last entry + today = datetime.now(local_tz).strftime("%Y-%m-%d") + filepath = os.path.join(diary_dir, "%s.md" % today) + if not os.path.exists(filepath): + print("No diary file for today (%s)" % today) + return + + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + + # Split by session markers and remove last + parts = content.split("### ⏰") + if len(parts) <= 1: + print("No session entries found in today's diary.") + return + + # Remove last entry (everything after last "### ⏰") + new_content = "### ⏰".join(parts[:-1]) + # Remove trailing "---\n\n" if present + new_content = new_content.rstrip() + if new_content.endswith("---"): + new_content = new_content[:-3].rstrip() + new_content += "\n\n" + + with open(filepath, "w", encoding="utf-8") as f: + f.write(new_content) + + print("Last session entry deleted from %s" % filepath) + return + + if args.session: + # Search all files for session ID and remove that entry + found = False + for f in sorted(Path(diary_dir).glob("*.md")): + try: + content = f.read_text(encoding="utf-8") + except Exception: + continue + if args.session in content: + parts = content.split("### ⏰") + new_parts = [parts[0]] + for part in parts[1:]: + if args.session not in part: + new_parts.append(part) + else: + found = True + new_content = "### ⏰".join(new_parts).rstrip() + "\n" + f.write_text(new_content, encoding="utf-8") + print("Session %s deleted from %s" % (args.session, f.name)) + break + + if not found: + print("Session '%s' not found." % args.session) + return + + print("Specify --last or --session ") + + # ── Dashboard ── def cmd_dashboard(args): diff --git a/src/claude_diary/core.py b/src/claude_diary/core.py index 8c6e204..a5a00ab 100644 --- a/src/claude_diary/core.py +++ b/src/claude_diary/core.py @@ -32,6 +32,12 @@ def process_session(session_id, transcript_path, cwd): diary_dir = os.path.expanduser(config.get("diary_dir", "~/working-diary")) enrichment = config.get("enrichment", {}) + # 0. Session opt-out check + from claude_diary.lib.team_security import should_skip_session + if should_skip_session(cwd, config): + sys.stderr.write("[diary] Session skipped (opt-out)\n") + return False + local_tz = timezone(timedelta(hours=tz_offset)) now = datetime.now(local_tz) date_str = now.strftime("%Y-%m-%d") @@ -101,6 +107,25 @@ def process_session(session_id, transcript_path, cwd): except Exception: pass + # 5.5 Team security filters (path masking + content filter) + try: + from claude_diary.lib.team_security import mask_paths, filter_entry_data + security = config.get("security", {}) + mask_patterns = security.get("mask_paths", []) + if mask_patterns: + entry_data["files_created"] = mask_paths(entry_data["files_created"], mask_patterns) + entry_data["files_modified"] = mask_paths(entry_data["files_modified"], mask_patterns) + + content_filters = security.get("content_filters", []) + filter_mode = security.get("filter_mode", "redact") + if content_filters: + should_record = filter_entry_data(entry_data, content_filters, filter_mode) + if not should_record: + sys.stderr.write("[diary] Session skipped (content filter)\n") + return False + except Exception: + pass + # 6. Format and write (CRITICAL — exit 1 on failure) try: entry_text = format_entry(entry_data, lang) diff --git a/src/claude_diary/lib/team_security.py b/src/claude_diary/lib/team_security.py new file mode 100644 index 0000000..9922dff --- /dev/null +++ b/src/claude_diary/lib/team_security.py @@ -0,0 +1,210 @@ +"""Team security module — path masking, content filtering, access control, opt-out.""" + +import fnmatch +import os +import re +import sys + + +# ── Path Masking ── + +def mask_paths(file_list, mask_patterns): + """Mask sensitive file paths using glob patterns. + + Args: + file_list: List of file path strings + mask_patterns: List of glob patterns (e.g., "**/credentials/**", "**/.env*") + + Returns: + New list with matched paths replaced by "[MASKED]" + """ + if not mask_patterns: + return file_list + + masked = [] + for fp in file_list: + normalized = fp.replace("\\", "/") + if _path_matches(normalized, mask_patterns): + masked.append("[MASKED]") + else: + masked.append(fp) + return masked + + +def _path_matches(path, patterns): + """Check if path matches any glob pattern.""" + for pattern in patterns: + # Normalize pattern + p = pattern.replace("\\", "/") + # Direct fnmatch + if fnmatch.fnmatch(path, p): + return True + # Check if any path component matches a keyword pattern + parts = path.lower().split("/") + p_parts = p.lower().replace("**/", "").replace("/**", "").split("/") + for keyword in p_parts: + if not keyword or keyword == "*": + continue + for part in parts: + if fnmatch.fnmatch(part, keyword): + return True + return False + + +# ── Content Filtering ── + +def filter_content(text, filter_keywords, mode="redact"): + """Filter sensitive content from text. + + Args: + text: Text to filter + filter_keywords: List of keywords to detect + mode: "redact" = replace sentence with [REDACTED], "skip" = return None + + Returns: + Filtered text, or None if mode=skip and keyword found + """ + if not text or not filter_keywords: + return text + + text_lower = text.lower() + for kw in filter_keywords: + if kw.lower() in text_lower: + if mode == "skip": + return None + elif mode == "redact": + # Replace the sentence containing the keyword + sentences = re.split(r'([.!?\n])', text) + result = [] + for i in range(0, len(sentences), 2): + sent = sentences[i] if i < len(sentences) else "" + sep = sentences[i + 1] if i + 1 < len(sentences) else "" + if kw.lower() in sent.lower(): + result.append("[REDACTED]" + sep) + else: + result.append(sent + sep) + return "".join(result) + + return text + + +def filter_entry_data(entry_data, filter_keywords, mode="redact"): + """Apply content filtering to all text fields of entry_data. + + If mode=skip and keyword found, returns False (skip entire session). + Otherwise modifies entry_data in-place and returns True. + """ + if not filter_keywords: + return True + + # Check if session should be skipped entirely + if mode == "skip": + all_text = " ".join( + entry_data.get("user_prompts", []) + + entry_data.get("summary_hints", []) + + entry_data.get("commands_run", []) + ) + for kw in filter_keywords: + if kw.lower() in all_text.lower(): + return False + + # Redact mode + for field in ("user_prompts", "summary_hints", "commands_run"): + items = entry_data.get(field, []) + filtered = [] + for item in items: + result = filter_content(item, filter_keywords, mode) + if result is not None: + filtered.append(result) + entry_data[field] = filtered + + return True + + +# ── Session Opt-out ── + +def should_skip_session(cwd, config): + """Check if the current session should be skipped. + + Checks: + 1. CLAUDE_DIARY_SKIP=1 environment variable + 2. Project name in config.skip_projects list + + Returns: + True if session should be skipped + """ + # Env var check + if os.environ.get("CLAUDE_DIARY_SKIP", "").strip() in ("1", "true", "yes"): + return True + + # Project skip list + skip_projects = config.get("skip_projects", []) + if skip_projects and cwd: + project = os.path.basename(cwd.replace("\\", "/").rstrip("/")) + if project in skip_projects: + return True + + return False + + +# ── Access Control ── + +ROLE_PERMISSIONS = { + "member": {"own_diary": "full", "others_diary": "summary", "others_detail": False, "team_stats": True}, + "lead": {"own_diary": "full", "others_diary": "full", "others_detail": "same_project", "team_stats": True}, + "admin": {"own_diary": "full", "others_diary": "full", "others_detail": True, "team_stats": True}, +} + + +def check_access(viewer_role, viewer_name, target_name, target_project=None, viewer_projects=None): + """Check if viewer has access to target's diary. + + Args: + viewer_role: "member", "lead", or "admin" + viewer_name: Name of the person viewing + target_name: Name of the diary owner + target_project: Project of the diary entry + viewer_projects: Projects the viewer is involved in + + Returns: + "full", "summary", or "none" + """ + if viewer_name == target_name: + return "full" + + perms = ROLE_PERMISSIONS.get(viewer_role, ROLE_PERMISSIONS["member"]) + + if viewer_role == "admin": + return "full" + + if viewer_role == "lead": + if target_project and viewer_projects and target_project in viewer_projects: + return "full" + return perms["others_diary"] + + # member + return "summary" + + +def apply_access_filter(entry_data, access_level): + """Filter entry_data based on access level. + + "full": no filtering + "summary": only project, categories, code_stats (no prompts, files, commands) + "none": empty dict + """ + if access_level == "full": + return entry_data + + if access_level == "none": + return {} + + # summary mode + return { + "date": entry_data.get("date", ""), + "time": entry_data.get("time", ""), + "project": entry_data.get("project", ""), + "categories": entry_data.get("categories", []), + "code_stats": entry_data.get("code_stats"), + "git_info": {"branch": entry_data.get("git_info", {}).get("branch", "")} if entry_data.get("git_info") else None, + } From c66780c60406283405e31295cd4ea405b8d768ba Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 17 Mar 2026 19:14:57 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20Sprint=20C-2=20+=20C-3=20=E2=80=94?= =?UTF-8?q?=20=ED=8C=80=20Git=20=EC=A4=91=EC=95=99=20Repo=20+=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit team.py: - init_team(): repo clone + .team-config.json 로드 + 보안 규칙 병합 - team_stats(): 팀원별/프로젝트별 세션 통계 - print_team_stats(): 터미널 대시보드 출력 - team_weekly_report(): 팀 주간 마크다운 리포트 생성 cli.py: - team 서브커맨드 (stats, weekly, monthly, init, add-member) - init --team 옵션 (팀 모드 초기화) - delete --last / --session (세션 삭제) .team-config.json 스키마: - team_name, members, roles (member/lead/admin) - security (mask_paths, content_filters, required_secret_scan) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/claude_diary/cli.py | 85 ++++++++++- src/claude_diary/team.py | 318 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 src/claude_diary/team.py diff --git a/src/claude_diary/cli.py b/src/claude_diary/cli.py index 1a0d88f..d55bfc3 100644 --- a/src/claude_diary/cli.py +++ b/src/claude_diary/cli.py @@ -60,11 +60,24 @@ def main(): p_config.add_argument("--add-exporter", help="Add exporter (interactive)") # init - sub.add_parser("init", help="Initialize claude-diary setup") + p_init = sub.add_parser("init", help="Initialize claude-diary setup") + p_init.add_argument("--team", dest="team_repo", help="Team repo URL for team mode") # migrate sub.add_parser("migrate", help="Migrate v1.0 env vars to config.json") + # team + p_team = sub.add_parser("team", help="Team management commands") + p_team.add_argument("action", nargs="?", default="stats", + choices=["stats", "weekly", "monthly", "init", "add-member"], + help="Team action") + p_team.add_argument("--project", "-p", help="Filter by project") + p_team.add_argument("--member", help="Filter by member") + p_team.add_argument("--month", "-m", help="Month (YYYY-MM)") + p_team.add_argument("--repo", help="Team repo URL (for init)") + p_team.add_argument("--name", help="Member name (for init/add-member)") + p_team.add_argument("--role", default="member", help="Role (for add-member)") + # reindex sub.add_parser("reindex", help="Rebuild search index") @@ -101,6 +114,7 @@ def main(): "init": cmd_init, "migrate": cmd_migrate, "reindex": cmd_reindex, + "team": cmd_team, "audit": cmd_audit, "delete": cmd_delete, "dashboard": cmd_dashboard, @@ -529,6 +543,14 @@ def cmd_init(args): config = load_config() diary_dir = os.path.expanduser(config["diary_dir"]) + # Team mode init + if hasattr(args, 'team_repo') and args.team_repo: + from claude_diary.team import init_team + print("Initializing claude-diary (team mode)...") + print() + init_team(args.team_repo) + return + print("Initializing claude-diary...") print() @@ -607,6 +629,67 @@ def cmd_reindex(args): print("Index: %s" % index_path) +# ── Team ── + +def cmd_team(args): + from claude_diary.team import ( + init_team, get_team_repo_path, team_stats, + print_team_stats, team_weekly_report + ) + + if args.action == "init": + repo_url = args.repo + if not repo_url: + repo_url = input("Team repo URL: ").strip() + name = args.name + if not name: + name = input("Your name: ").strip() + print("Initializing team mode...") + init_team(repo_url, name) + print("\nDone! Sessions will auto-push to team repo.") + return + + config = load_config() + repo_path = get_team_repo_path(config) + if not repo_path or not os.path.isdir(repo_path): + print("Team not configured. Run: claude-diary team init --repo ") + return + + if args.action == "stats": + data = team_stats(repo_path, month=args.month) + print_team_stats(data) + + elif args.action in ("weekly", "monthly"): + lang = config.get("lang", "ko") + result = team_weekly_report(repo_path, lang=lang) + if result: + report, filepath = result + print(report) + print("---") + print("Saved: %s" % filepath) + else: + print("No team data found.") + + elif args.action == "add-member": + name = args.name or input("Member name: ").strip() + role = args.role + team_config_path = os.path.join(repo_path, ".team-config.json") + tc = {} + if os.path.exists(team_config_path): + with open(team_config_path, "r") as f: + tc = json.load(f) + tc.setdefault("members", []) + tc.setdefault("roles", {}) + if name not in tc["members"]: + tc["members"].append(name) + tc["roles"][name] = role + with open(team_config_path, "w") as f: + json.dump(tc, f, indent=2, ensure_ascii=False) + # Create member dir + Path(os.path.join(repo_path, "members", name)).mkdir(parents=True, exist_ok=True) + print("Added member '%s' with role '%s'" % (name, role)) + + # ── Audit ── def cmd_audit(args): diff --git a/src/claude_diary/team.py b/src/claude_diary/team.py new file mode 100644 index 0000000..58639eb --- /dev/null +++ b/src/claude_diary/team.py @@ -0,0 +1,318 @@ +"""Team management — team config, member management, team CLI commands.""" + +import json +import os +import subprocess +import sys +from collections import Counter +from datetime import datetime, timezone, timedelta +from pathlib import Path + +from claude_diary.config import load_config +from claude_diary.lib.stats import parse_daily_file + + +def load_team_config(team_repo_path): + """Load .team-config.json from a team repo.""" + config_path = os.path.join(team_repo_path, ".team-config.json") + if not os.path.exists(config_path): + return None + try: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + + +def get_team_repo_path(config=None): + """Get the team repo local path from config.""" + if config is None: + config = load_config() + team = config.get("team", {}) + path = team.get("repo_path", "") + return os.path.expanduser(path) if path else None + + +def init_team(repo_url, member_name=None): + """Initialize team mode — clone repo and configure. + + Args: + repo_url: Git clone URL for team diary repo + member_name: Team member name (defaults to OS username) + """ + config = load_config() + + if not member_name: + member_name = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown" + + # Clone team repo + diary_dir = os.path.expanduser(config.get("diary_dir", "~/working-diary")) + team_repo_path = os.path.join(diary_dir, ".team-repo") + + if os.path.exists(team_repo_path): + print(" [ok] Team repo already exists: %s" % team_repo_path) + else: + print(" Cloning team repo...") + try: + result = subprocess.run( + ["git", "clone", repo_url, team_repo_path], + capture_output=True, text=True, timeout=60 + ) + if result.returncode != 0: + print(" [error] Clone failed: %s" % result.stderr.strip()) + return False + print(" [ok] Cloned: %s" % team_repo_path) + except Exception as e: + print(" [error] Clone failed: %s" % str(e)) + return False + + # Load team config + team_config = load_team_config(team_repo_path) + if team_config: + print(" [ok] Team: %s" % team_config.get("team_name", "unknown")) + print(" [ok] Members: %s" % ", ".join(team_config.get("members", []))) + + # Merge team security rules (team overrides personal, strengthen only) + team_security = team_config.get("security", {}) + if team_security: + if "security" not in config: + config["security"] = {} + # Merge mask_paths (additive) + existing = set(config["security"].get("mask_paths", [])) + existing.update(team_security.get("mask_paths", [])) + config["security"]["mask_paths"] = sorted(existing) + # Merge content_filters (additive) + existing_filters = set(config["security"].get("content_filters", [])) + existing_filters.update(team_security.get("content_filters", [])) + config["security"]["content_filters"] = sorted(existing_filters) + print(" [ok] Team security rules loaded") + + # Update personal config + config.setdefault("team", {}) + config["team"]["repo_path"] = team_repo_path + config["team"]["repo_url"] = repo_url + config["team"]["member_name"] = member_name + config["team"]["push_strategy"] = "auto" + + # Auto-enable GitHub exporter for team + config.setdefault("exporters", {}) + config["exporters"]["github"] = { + "enabled": True, + "mode": "repo", + "local_path": team_repo_path, + "member_name": member_name, + } + + from claude_diary.config import save_config + save_config(config) + print(" [ok] Config updated with team settings") + + # Create member directory + member_dir = os.path.join(team_repo_path, "members", member_name) + Path(member_dir).mkdir(parents=True, exist_ok=True) + print(" [ok] Member directory: %s" % member_dir) + + return True + + +def team_stats(team_repo_path, month=None): + """Generate team statistics from the team repo.""" + config = load_config() + tz_offset = config.get("timezone_offset", 9) + local_tz = timezone(timedelta(hours=tz_offset)) + now = datetime.now(local_tz) + + if month: + year, mon = month.split("-") + year, mon = int(year), int(mon) + else: + year, mon = now.year, now.month + + members_dir = os.path.join(team_repo_path, "members") + if not os.path.isdir(members_dir): + print("No members directory found in team repo.") + return + + import calendar + _, days_in_month = calendar.monthrange(year, mon) + + member_stats = {} + all_projects = Counter() + total_sessions = 0 + + for member_name in sorted(os.listdir(members_dir)): + member_path = os.path.join(members_dir, member_name) + if not os.path.isdir(member_path): + continue + + m_sessions = 0 + m_projects = Counter() + m_categories = Counter() + m_files = 0 + + for day in range(1, days_in_month + 1): + date_str = "%04d-%02d-%02d" % (year, mon, day) + filepath = os.path.join(member_path, "%s.md" % date_str) + stats = parse_daily_file(filepath) + m_sessions += stats["sessions"] + for p in stats["projects"]: + m_projects[p] += stats["sessions"] + all_projects[p] += stats["sessions"] + for c in stats.get("categories", []): + m_categories[c] += 1 + m_files += len(stats["files_modified"]) + len(stats["files_created"]) + + if m_sessions > 0: + member_stats[member_name] = { + "sessions": m_sessions, + "projects": m_projects, + "categories": m_categories, + "files": m_files, + } + total_sessions += m_sessions + + return { + "total_sessions": total_sessions, + "members": member_stats, + "projects": all_projects, + "month": "%04d-%02d" % (year, mon), + } + + +def print_team_stats(stats_data): + """Print team stats to terminal.""" + if not stats_data or not stats_data["members"]: + print("No team activity found.") + return + + month = stats_data["month"] + total = stats_data["total_sessions"] + members = stats_data["members"] + projects = stats_data["projects"] + + print() + width = 52 + print("+" + "=" * width + "+") + print("| Team Stats — %s%s|" % (month, " " * (width - 17 - len(month)))) + print("+" + "=" * width + "+") + print() + print(" Members: %d | Sessions: %d | Projects: %d" % ( + len(members), total, len(projects) + )) + print() + + if projects: + print(" Projects:") + max_count = max(projects.values()) + for proj, count in projects.most_common(10): + bar_len = int(count / max_count * 16) + bar = "+" * bar_len + " " * (16 - bar_len) + # Show per-member breakdown + breakdown = [] + for m, ms in members.items(): + mc = ms["projects"].get(proj, 0) + if mc > 0: + breakdown.append("%s:%d" % (m, mc)) + bd_str = " (%s)" % " ".join(breakdown) if breakdown else "" + print(" %-16s %s %d%s" % (proj, bar, count, bd_str)) + print() + + print(" Members:") + max_sessions = max(m["sessions"] for m in members.values()) + for name, ms in sorted(members.items(), key=lambda x: -x[1]["sessions"]): + bar_len = int(ms["sessions"] / max_sessions * 16) + bar = "+" * bar_len + " " * (16 - bar_len) + cats = " ".join("%s(%d)" % (c, n) for c, n in ms["categories"].most_common(3)) + print(" %-10s %s %d %s" % (name, bar, ms["sessions"], cats)) + print() + print("+" + "=" * width + "+") + + +def team_weekly_report(team_repo_path, target_date=None, lang="ko"): + """Generate team weekly report.""" + config = load_config() + tz_offset = config.get("timezone_offset", 9) + local_tz = timezone(timedelta(hours=tz_offset)) + + if target_date: + from datetime import date as date_cls + target = datetime.strptime(target_date, "%Y-%m-%d").date() + else: + target = datetime.now(local_tz).date() + + monday = target - timedelta(days=target.weekday()) + dates = [monday + timedelta(days=i) for i in range(7)] + week_num = monday.isocalendar()[1] + + members_dir = os.path.join(team_repo_path, "members") + if not os.path.isdir(members_dir): + return None + + lines = [] + if lang == "ko": + lines.append("# Team Weekly Report — W%d" % week_num) + else: + lines.append("# Team Weekly Report — W%d" % week_num) + lines.append("### %s ~ %s" % (monday.strftime("%Y-%m-%d"), dates[-1].strftime("%Y-%m-%d"))) + lines.append("") + + total_sessions = 0 + member_summaries = {} + + for member_name in sorted(os.listdir(members_dir)): + member_path = os.path.join(members_dir, member_name) + if not os.path.isdir(member_path): + continue + + m_sessions = 0 + m_tasks = [] + m_projects = set() + m_categories = Counter() + + for date in dates: + date_str = date.strftime("%Y-%m-%d") + filepath = os.path.join(member_path, "%s.md" % date_str) + stats = parse_daily_file(filepath) + m_sessions += stats["sessions"] + m_tasks.extend(stats["tasks"][:3]) + m_projects |= stats["projects"] + for c in stats.get("categories", []): + m_categories[c] += 1 + + if m_sessions > 0: + total_sessions += m_sessions + member_summaries[member_name] = { + "sessions": m_sessions, + "tasks": m_tasks, + "projects": m_projects, + "categories": m_categories, + } + + lines.append("| Item | Count |") + lines.append("|------|-------|") + lines.append("| Total Sessions | **%d** |" % total_sessions) + lines.append("| Active Members | **%d** |" % len(member_summaries)) + lines.append("") + + for name, summary in member_summaries.items(): + cats = " ".join("%s(%d)" % (c, n) for c, n in summary["categories"].most_common(3)) + lines.append("### %s (%d sessions)" % (name, summary["sessions"])) + lines.append("- Projects: %s" % ", ".join("`%s`" % p for p in summary["projects"])) + if cats: + lines.append("- Categories: %s" % cats) + if summary["tasks"]: + for t in summary["tasks"][:3]: + lines.append(" - %s" % t) + lines.append("") + + report = "\n".join(lines) + + # Save + weekly_dir = os.path.join(team_repo_path, "weekly") + Path(weekly_dir).mkdir(parents=True, exist_ok=True) + filename = "team-W%02d_%s.md" % (week_num, monday.strftime("%Y-%m-%d")) + filepath = os.path.join(weekly_dir, filename) + with open(filepath, "w", encoding="utf-8") as f: + f.write(report) + + return report, filepath From 0d1e3c7de42839df5fd94c349c8f91b4e926d6cc Mon Sep 17 00:00:00 2001 From: cys Date: Tue, 17 Mar 2026 19:16:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20Sprint=20C-4=20=E2=80=94=20Notion?= =?UTF-8?q?=20=ED=8C=80=20DB=20(Author=20=EC=BB=AC=EB=9F=BC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=84=A4=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notion.py: member_name config 시 Author Select 컬럼 자동 추가 팀 Notion DB에서 작성자별 필터/정렬 가능 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/claude_diary/exporters/notion.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/claude_diary/exporters/notion.py b/src/claude_diary/exporters/notion.py index 8797e3e..3a36fbd 100644 --- a/src/claude_diary/exporters/notion.py +++ b/src/claude_diary/exporters/notion.py @@ -23,6 +23,9 @@ def export(self, entry_data): token = self.config["api_token"] db_id = self.config["database_id"] + # Team mode: add author column + member_name = self.config.get("member_name", "") + # Build Notion page properties categories = entry_data.get("categories", []) prompts = entry_data.get("user_prompts", []) @@ -41,6 +44,10 @@ def export(self, entry_data): "Work Summary": {"rich_text": [{"text": {"content": "\n".join(hints[:5])[:2000]}}]}, } + # Team mode: add Author column + if member_name: + properties["Author"] = {"select": {"name": member_name}} + # Git info commits = git_info.get("commits", []) if commits: