From a3ff8ac5fe85948be7fe37bcdbb5c9285bc6cf1a Mon Sep 17 00:00:00 2001 From: cys Date: Fri, 5 Jun 2026 11:56:51 +0900 Subject: [PATCH 1/6] feat: add phase 3 notion operations report --- CHANGELOG.md | 2 + README.en.md | 3 + README.md | 3 + src/claude_diary/cli/__init__.py | 11 +- src/claude_diary/cli/notion_ensure.py | 66 +- src/claude_diary/cli/notion_ops.py | 571 ++++++++++++++++++ .../exporters/notion_hierarchical.py | 18 + tests/test_notion_ensure.py | 49 +- tests/test_notion_hierarchical.py | 31 + tests/test_notion_ops.py | 272 +++++++++ 10 files changed, 1018 insertions(+), 8 deletions(-) create mode 100644 src/claude_diary/cli/notion_ops.py create mode 100644 tests/test_notion_ops.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 496f963..f236b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Working Diary OS 비전 문서**: Structure → Views → Operations → Intelligence → Multi-project OS로 확장하는 최고모델 설계, 최소 명령 원칙, 전날 todo 기반 `today-plan`, schema/view conflict drift 관리 방향 추가 - **2차 View 설계 문서**: `working-diary diary-notion ensure`, `--year`, `--dry-run`과 Core Views 5개, operating views 5개, `Work Period`와 `Sub-items` 기반 schema v7 방향, 하위 항목 데이터 구조, sub-item UI best-effort/fallback, partial failure/exit code 정책 정리 - **`working-diary diary-notion ensure` 구현**: schema v7 `Work Period`, native sub-item relation, Priority/Blocked/Review 운영 컬럼 보장, Core Views 5개와 Operating Views 5개 생성/검증/update, `--year`, `--dry-run`, required setting repair 지원 +- **`working-diary diary-notion ops` 구현**: Notion Entries DB를 읽기 전용으로 조회해 blocked/review/next action/stale/work days/today-plan 후보/task group/project/parent progress/부모 상태 제안 운영 신호를 요약 +- **Phase 3 ensure diagnostics**: schema/view conflict reason을 `missing_filter`, `missing_property`, `subitem_missing`, `permission_or_auth`, `api_failure`로 분류하고 repair plan/action/apply 가능 여부를 출력 - **`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 cf20b3a..18cf7cd 100644 --- a/README.en.md +++ b/README.en.md @@ -335,6 +335,7 @@ working-diary write working-diary diary-notion init working-diary diary-notion ensure working-diary diary-notion ensure --dry-run +working-diary diary-notion ops working-diary diary-notion push --input .diary-notion-.json working-diary notion push --input .diary-notion-.json ``` @@ -407,6 +408,7 @@ $OutputEncoding = [System.Text.UTF8Encoding]::new() - Secret scanning and masking - Search index - Notion work log: `working-diary diary-notion init` -> `working-diary diary-notion ensure` +- Notion operations report: `working-diary diary-notion ops` for blocked/review/next action/stale/work days/today-plan candidates/parent status signals - Slack, Discord, Obsidian, GitHub exporters: `working-diary config --add-exporter ` - HTML dashboard: `working-diary dashboard` or `working-diary dashboard --serve --port 8787` - Audit log and source checksum verification @@ -438,6 +440,7 @@ This README focuses on currently usable functionality. Detailed design and plann | Area | Status | |------|--------| | Stable now | Claude Code Stop Hook, Codex skills, Markdown diaries, Notion task row push, schema/view ensure | +| Phase 3 started | `working-diary diary-notion ops` read-only operations report, parent/task group progress, ensure conflict classification and repair plans | | Next | Windows install/output experience, Notion sub-item guidance, incremental CI/lint expansion | | Under review | SQLite search index, Cursor/Windsurf/VS Code integration | diff --git a/README.md b/README.md index ac43eb2..80c0b4a 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ working-diary write working-diary diary-notion init working-diary diary-notion ensure working-diary diary-notion ensure --dry-run +working-diary diary-notion ops working-diary diary-notion push --input .diary-notion-.json working-diary notion push --input .diary-notion-.json ``` @@ -411,6 +412,7 @@ $OutputEncoding = [System.Text.UTF8Encoding]::new() - secret scan과 masking - 검색 인덱스 - Notion 업무일지: `working-diary diary-notion init` -> `working-diary diary-notion ensure` +- Notion 운영 진단: `working-diary diary-notion ops`로 blocked/review/next action/stale/work days/today-plan 후보/부모 상태 제안 확인 - Slack, Discord, Obsidian, GitHub exporter: `working-diary config --add-exporter ` - HTML dashboard: `working-diary dashboard` 또는 `working-diary dashboard --serve --port 8787` - audit log와 source checksum 검증 @@ -442,6 +444,7 @@ python -m ruff check . | 구분 | 내용 | |------|------| | 현재 안정화 | Claude Code Stop Hook, Codex skill, Markdown 일지, Notion task row push, schema/view ensure | +| Phase 3 진입 | `working-diary diary-notion ops` 읽기 전용 운영 진단, parent/task group 진행률, ensure conflict 분류와 repair plan | | 다음 개선 | Windows 설치/출력 경험 정리, Notion sub-item 안내 개선, CI/lint 범위 점진 확대 | | 검토 중 | SQLite 기반 검색 인덱스, Cursor/Windsurf/VS Code 같은 다른 AI IDE 연동 | diff --git a/src/claude_diary/cli/__init__.py b/src/claude_diary/cli/__init__.py index 5092a79..be96b86 100644 --- a/src/claude_diary/cli/__init__.py +++ b/src/claude_diary/cli/__init__.py @@ -23,6 +23,7 @@ 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 +from claude_diary.cli.notion_ops import cmd_notion_ops def cmd_notion(args): @@ -33,17 +34,23 @@ def cmd_notion(args): cmd_notion_init(args) elif args.action == "ensure": cmd_notion_ensure(args) + elif args.action == "ops": + cmd_notion_ops(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("action", choices=["init", "push", "ensure", "ops"], 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("--year", type=int, help="Target year (ensure/ops only)") p_notion.add_argument("--dry-run", action="store_true", help="Print the Notion schema/view plan without writing (ensure only)") + p_notion.add_argument("--stale-days", type=int, default=7, + help="Mark active rows stale after N days (ops only)") + p_notion.add_argument("--json", dest="json_output", action="store_true", + help="Output operations report as JSON (ops only)") def main(): diff --git a/src/claude_diary/cli/notion_ensure.py b/src/claude_diary/cli/notion_ensure.py index 17486a6..3206754 100644 --- a/src/claude_diary/cli/notion_ensure.py +++ b/src/claude_diary/cli/notion_ensure.py @@ -114,10 +114,23 @@ def _print_ensure_report(root_page_id, year, db_id, schema_status, result, dry_r 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: check view permissions or fix the required setting, then rerun") + plan = build_conflict_plan(conflict.name, conflict.reason) + print(" x %s -- conflict[%s]: %s" % ( + conflict.name, + plan["category"], + conflict.reason, + )) + print(" action: %s" % plan["action"]) + print(" apply: %s" % ("yes" if plan["apply_supported"] else "manual")) for failure in result.failed: - print(" ! %s -- %s" % (failure.name, failure.reason)) + plan = build_conflict_plan(failure.name, failure.reason) + print(" ! %s -- %s [%s]" % ( + failure.name, + failure.reason, + plan["category"], + )) + print(" action: %s" % plan["action"]) + print(" apply: %s" % ("yes" if plan["apply_supported"] else "manual")) if result.repaired: print("Sub-item sync:") for entry in result.repaired: @@ -125,4 +138,49 @@ def _print_ensure_report(root_page_id, year, db_id, schema_status, result, dry_r if result.warnings: print("Warnings:") for warning in result.warnings: - print(" ! %s" % warning) + plan = build_conflict_plan("warning", warning) + print(" ! %s [%s]" % (warning, plan["category"])) + print(" action: %s" % plan["action"]) + print(" apply: %s" % ("yes" if plan["apply_supported"] else "manual")) + + +def build_conflict_plan(name, reason): + """Return a structured read-only repair plan for an ensure problem.""" + category = classify_conflict_reason(reason) + if category == "missing_filter": + return _plan(name, reason, category, "rerun `working-diary diary-notion ensure` to repair the view filter", True) + if category == "missing_property": + return _plan(name, reason, category, "rerun `working-diary diary-notion ensure` to add the missing schema property", True) + if category == "subitem_missing": + return _plan(name, reason, category, "enable Notion Sub-items in the Entries DB UI, then rerun ensure", False) + if category == "permission_or_auth": + return _plan(name, reason, category, "share the root page/database with the integration or refresh the token", False) + if category == "api_failure": + return _plan(name, reason, category, "inspect the Notion API error, then rerun ensure after fixing the payload or permission issue", False) + return _plan(name, reason, category, "inspect the reported issue, then rerun ensure", False) + + +def _plan(name, reason, category, action, apply_supported): + return { + "name": name, + "reason": reason, + "category": category, + "action": action, + "apply_supported": apply_supported, + } + + +def classify_conflict_reason(reason): + """Return a stable Phase 3 conflict category for ensure diagnostics.""" + text = (reason or "").lower() + if "native sub-items not enabled" in text or "sub-items" in text or "subitem" in text: + return "subitem_missing" + if "missing" in text and "filter" in text: + return "missing_filter" + if "missing" in text and ("property" in text or "column" in text): + return "missing_property" + if "permission" in text or "403" in text or "unauthorized" in text or "auth" in text: + return "permission_or_auth" + if "api" in text or "400" in text or "failed" in text: + return "api_failure" + return "unknown" diff --git a/src/claude_diary/cli/notion_ops.py b/src/claude_diary/cli/notion_ops.py new file mode 100644 index 0000000..fceebc6 --- /dev/null +++ b/src/claude_diary/cli/notion_ops.py @@ -0,0 +1,571 @@ +"""`working-diary diary-notion ops` -- read-only Notion operations report.""" + +import json +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, + detect_subitem_relation, +) + + +DONE_STATUSES = {"Deployed"} +ACTIVE_STATUSES = {"Discussion", "Design", "Implementation", "Testing"} +PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3, "": 9} + + +def cmd_notion_ops(args): + """Print a read-only operations report for the yearly Entries database.""" + 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)) + stale_days = getattr(args, "stale_days", None) + if stale_days is None: + stale_days = 7 + json_output = getattr(args, "json_output", False) is True + + exporter = NotionHierarchicalExporter({ + "api_token": token, + "root_page_id": root_page_id, + }) + exporter.load_cache() + + try: + db_id = exporter.resolve_existing_database(year) + if not db_id: + _print_missing_database(year, json_output) + sys.exit(1) + rows = exporter.query_database_rows(db_id) + prop_map = exporter.get_database_property_map(db_id) + except NotionAuthError as e: + print("[working-diary diary-notion ops] 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 ops] 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 ops] Bad request: %s" % e, file=sys.stderr) + sys.exit(1) + except Exception as e: + print("[working-diary diary-notion ops] Failed: %s" % e, file=sys.stderr) + sys.exit(1) + + native = detect_subitem_relation(prop_map) + parent_property_name = native["parent_name"] if native else "Parent Task" + report = build_ops_report(rows, today, stale_days, parent_property_name) + if json_output: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + _print_ops_report(year, db_id, report) + + +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 build_ops_report(rows, today, stale_days=7, parent_property_name="Parent Task"): + """Build a read-only operations report from Notion page rows.""" + items = [_row_to_item(row, parent_property_name) for row in rows if not row.get("archived")] + active = [item for item in items if not _is_done(item)] + + blocked = [item for item in active if item["blocked"]] + needs_review = [item for item in active if item["review_status"] == "Needs Review"] + missing_next_action = [ + item for item in active + if item["status"] in ("Implementation", "Testing") and not item["next_action"] + ] + stale = [ + item for item in active + if _days_since(item["work_period_start"] or item["date"], today) is not None + and _days_since(item["work_period_start"] or item["date"], today) >= stale_days + ] + verification_candidates = _verification_candidates(active) + today_plan_candidates = _today_plan_candidates(active, today) + + parent_status_suggestions = _parent_status_suggestions(items) + parent_progress = _parent_progress(items) + task_groups = {} + projects = {} + for item in items: + _accumulate_group(task_groups, item["task_group"] or "(no task group)", item) + _accumulate_group(projects, item["project"] or "(no project)", item) + + task_groups = _finalize_groups(task_groups) + projects = _finalize_groups(projects) + + return { + "today": today, + "stale_days": stale_days, + "counts": { + "total": len(items), + "active": len(active), + "blocked": len(blocked), + "needs_review": len(needs_review), + "missing_next_action": len(missing_next_action), + "stale": len(stale), + "verification_candidates": len(verification_candidates), + "today_plan_candidates": len(today_plan_candidates), + "parent_status_suggestions": len(parent_status_suggestions), + }, + "blocked": _compact_items(blocked), + "needs_review": _compact_items(needs_review), + "missing_next_action": _compact_items(missing_next_action), + "stale": _compact_items(stale), + "verification_candidates": _compact_items(verification_candidates, include_reason=True), + "today_plan_candidates": _compact_items(today_plan_candidates), + "parent_status_suggestions": parent_status_suggestions, + "parent_progress": parent_progress, + "task_groups": task_groups, + "projects": projects, + } + + +def _row_to_item(row, parent_property_name="Parent Task"): + props = row.get("properties") or {} + return { + "id": row.get("id", ""), + "title": _title(props.get("Name")), + "project": _select(props.get("Project")), + "purpose": _select(props.get("Purpose")), + "status": _select(props.get("Status")), + "priority": _select(props.get("Priority")), + "task_group": _select(props.get("Task Group")), + "review_status": _select(props.get("Review Status")), + "next_action": _rich_text(props.get("Next Action")), + "blocked": _checkbox(props.get("Blocked")), + "block_reason": _rich_text(props.get("Block Reason")), + "date": _date_start(props.get("Date")), + "work_period_start": _date_start(props.get("Work Period")), + "parent_ids": _relation_ids(props.get(parent_property_name)), + "url": row.get("url", ""), + } + + +def _compact_items(items, limit=10, include_reason=False): + result = [] + for item in items[:limit]: + compact = { + "title": item["title"], + "project": item["project"], + "task_group": item["task_group"], + "status": item["status"], + "priority": item["priority"], + "next_action": item["next_action"], + "block_reason": item["block_reason"], + "work_period_start": item["work_period_start"], + "url": item["url"], + } + if include_reason: + compact["reason"] = item.get("reason", "") + result.append(compact) + return result + + +def _is_done(item): + return item["status"] in DONE_STATUSES + + +def _accumulate_group(groups, name, item): + entry = groups.setdefault(name, { + "total": 0, + "active": 0, + "blocked": 0, + "needs_review": 0, + "done": 0, + "first_worked_on": "", + "last_worked_on": "", + "_work_days": set(), + }) + entry["total"] += 1 + if _is_done(item): + entry["done"] += 1 + else: + entry["active"] += 1 + if item["blocked"]: + entry["blocked"] += 1 + if item["review_status"] == "Needs Review": + entry["needs_review"] += 1 + work_day = (item["work_period_start"] or item["date"])[:10] + if work_day: + entry["_work_days"].add(work_day) + if not entry["first_worked_on"] or work_day < entry["first_worked_on"]: + entry["first_worked_on"] = work_day + if not entry["last_worked_on"] or work_day > entry["last_worked_on"]: + entry["last_worked_on"] = work_day + + +def _finalize_groups(groups): + result = {} + for name, entry in groups.items(): + finalized = dict(entry) + work_days = finalized.pop("_work_days", set()) + finalized["work_days"] = len(work_days) + finalized["done_ratio"] = _ratio(finalized["done"], finalized["total"]) + result[name] = finalized + return result + + +def _parent_progress(items): + by_id = { + item["id"]: item + for item in items + if item["id"] + } + children_by_parent = {} + for item in items: + for parent_id in item["parent_ids"]: + children_by_parent.setdefault(parent_id, []).append(item) + + progress = [] + for parent_id, children in children_by_parent.items(): + parent = by_id.get(parent_id) + if not parent or not children: + continue + done = sum(1 for child in children if _is_done(child)) + blocked = sum(1 for child in children if child["blocked"]) + progress.append({ + "title": parent["title"], + "project": parent["project"], + "task_group": parent["task_group"], + "status": parent["status"], + "children": len(children), + "done": done, + "active": len(children) - done, + "blocked": blocked, + "done_ratio": _ratio(done, len(children)), + "url": parent["url"], + }) + return sorted( + progress, + key=lambda item: ( + item["done_ratio"], + item["title"], + ), + ) + + +def _parent_status_suggestions(items): + by_id = { + item["id"]: item + for item in items + if item["id"] + } + children_by_parent = {} + for item in items: + for parent_id in item["parent_ids"]: + children_by_parent.setdefault(parent_id, []).append(item) + + suggestions = [] + for parent_id, children in children_by_parent.items(): + parent = by_id.get(parent_id) + if not parent or not children: + continue + suggestion = _suggest_parent_status(parent, children) + if suggestion: + suggestions.append(suggestion) + return suggestions + + +def _suggest_parent_status(parent, children): + child_statuses = {child["status"] for child in children} + if all(_is_done(child) for child in children): + return _parent_suggestion(parent, "Deployed", "all child tasks are Deployed", children) + if any(child["blocked"] for child in children): + if parent["blocked"]: + return None + return _parent_suggestion(parent, parent["status"] or "Implementation", "at least one child task is blocked", children) + if "Testing" in child_statuses and parent["status"] not in ("Testing", "Deployed"): + return _parent_suggestion(parent, "Testing", "at least one child task is in Testing", children) + if "Implementation" in child_statuses and parent["status"] in ("", "Discussion", "Design"): + return _parent_suggestion(parent, "Implementation", "at least one child task is in Implementation", children) + return None + + +def _parent_suggestion(parent, suggested_status, reason, children): + return { + "title": parent["title"], + "project": parent["project"], + "task_group": parent["task_group"], + "status": parent["status"], + "suggested_status": suggested_status, + "reason": reason, + "child_count": len(children), + "url": parent["url"], + } + + +def _ratio(part, total): + if not total: + return 0.0 + return round(float(part) / float(total), 4) + + +def _today_plan_candidates(items, today): + candidates = [] + for item in items: + if item["blocked"] or not item["next_action"]: + continue + age = _days_since(item["work_period_start"] or item["date"], today) + if age is None or age <= 0: + continue + candidate = dict(item) + candidate["age_days"] = age + candidates.append(candidate) + return sorted( + candidates, + key=lambda item: ( + PRIORITY_RANK.get(item["priority"], 9), + -item["age_days"], + item["title"], + ), + ) + + +def _verification_candidates(items): + candidates = [] + for item in items: + if item["blocked"] or item["status"] not in ("Implementation", "Testing"): + continue + reasons = [] + if item["review_status"] != "Reviewed": + reasons.append("review_status_not_reviewed") + if item["status"] == "Testing" and not item["next_action"]: + reasons.append("testing_without_next_action") + if item["status"] == "Implementation" and item["review_status"] in ("", "Deferred"): + reasons.append("implementation_review_deferred") + if not reasons: + continue + candidate = dict(item) + candidate["reason"] = ", ".join(reasons) + candidates.append(candidate) + return sorted( + candidates, + key=lambda item: ( + item["status"] != "Testing", + PRIORITY_RANK.get(item["priority"], 9), + item["title"], + ), + ) + + +def _days_since(date_str, today): + if not date_str: + return None + try: + start = datetime.strptime(date_str[:10], "%Y-%m-%d").date() + end = datetime.strptime(today[:10], "%Y-%m-%d").date() + except ValueError: + return None + return (end - start).days + + +def _title(prop): + values = (prop or {}).get("title") or [] + text = "".join(_plain_text(item) for item in values).strip() + return text or "(untitled)" + + +def _rich_text(prop): + values = (prop or {}).get("rich_text") or [] + return "".join(_plain_text(item) for item in values).strip() + + +def _plain_text(item): + return item.get("plain_text") or ((item.get("text") or {}).get("content")) or "" + + +def _select(prop): + return ((prop or {}).get("select") or {}).get("name") or "" + + +def _checkbox(prop): + return bool((prop or {}).get("checkbox")) + + +def _date_start(prop): + return ((prop or {}).get("date") or {}).get("start") or "" + + +def _relation_ids(prop): + return [ + item.get("id") + for item in (prop or {}).get("relation") or [] + if item.get("id") + ] + + +def _print_missing_database(year, json_output): + if json_output: + print(json.dumps({ + "year": year, + "error": "database_missing", + }, ensure_ascii=False, indent=2)) + return + print("[working-diary diary-notion ops]") + print("Year: %s" % year) + print("Database: missing") + print("Run `working-diary diary-notion ensure --year %s` first." % year) + + +def _print_ops_report(year, db_id, report): + counts = report["counts"] + print("[working-diary diary-notion ops]") + print("Year: %s" % year) + print("Database: %s" % db_id) + print("Rows: %d total, %d active" % (counts["total"], counts["active"])) + print("Signals:") + print(" Blocked: %d" % counts["blocked"]) + print(" Needs review: %d" % counts["needs_review"]) + print(" Missing next action: %d" % counts["missing_next_action"]) + print(" Stale >= %d day(s): %d" % (report["stale_days"], counts["stale"])) + print(" Verification candidates: %d" % counts["verification_candidates"]) + print(" Today-plan candidates: %d" % counts["today_plan_candidates"]) + print(" Parent status suggestions: %d" % counts["parent_status_suggestions"]) + _print_section("Blocked", report["blocked"]) + _print_section("Needs Review", report["needs_review"]) + _print_section("Missing Next Action", report["missing_next_action"]) + _print_section("Stale Work", report["stale"]) + _print_section("Today Plan Candidates", report["today_plan_candidates"]) + _print_parent_progress(report["parent_progress"]) + _print_parent_status_suggestions(report["parent_status_suggestions"]) + _print_group_summary(report["task_groups"]) + _print_project_summary(report["projects"]) + + +def _print_section(name, items): + if not items: + return + print("%s:" % name) + for item in items: + suffix = [] + if item["project"]: + suffix.append(item["project"]) + if item["task_group"]: + suffix.append(item["task_group"]) + if item["status"]: + suffix.append(item["status"]) + detail = " | ".join(suffix) + print(" - %s%s" % (item["title"], " (%s)" % detail if detail else "")) + if item.get("reason"): + print(" reason: %s" % item["reason"]) + if item["next_action"]: + print(" next: %s" % item["next_action"]) + if item["block_reason"]: + print(" blocked: %s" % item["block_reason"]) + + +def _print_group_summary(groups): + if not groups: + return + print("Task Groups:") + for name in sorted(groups): + data = groups[name] + print( + " - %s: %d total, %d active, %d done, %d blocked, %d review, %d work day(s)%s" % + ( + name, + data["total"], + data["active"], + data["done"], + data["blocked"], + data["needs_review"], + data["work_days"], + _period_suffix(data), + ) + ) + + +def _print_project_summary(projects): + if not projects: + return + print("Projects:") + for name in sorted(projects): + data = projects[name] + print( + " - %s: %d total, %d active, %d done, %d work day(s)%s" % + ( + name, + data["total"], + data["active"], + data["done"], + data["work_days"], + _period_suffix(data), + ) + ) + + +def _print_parent_status_suggestions(items): + if not items: + return + print("Parent Status Suggestions:") + for item in items: + detail = [] + if item["project"]: + detail.append(item["project"]) + if item["task_group"]: + detail.append(item["task_group"]) + suffix = " (%s)" % " | ".join(detail) if detail else "" + print( + " - %s%s: %s -> %s (%s, %d child task(s))" % + ( + item["title"], + suffix, + item["status"] or "(unset)", + item["suggested_status"], + item["reason"], + item["child_count"], + ) + ) + + +def _print_parent_progress(items): + if not items: + return + print("Parent Progress:") + for item in items: + detail = [] + if item["project"]: + detail.append(item["project"]) + if item["task_group"]: + detail.append(item["task_group"]) + suffix = " (%s)" % " | ".join(detail) if detail else "" + percent = int(round(item["done_ratio"] * 100)) + print( + " - %s%s: %d/%d done, %d active, %d blocked (%d%%)" % + ( + item["title"], + suffix, + item["done"], + item["children"], + item["active"], + item["blocked"], + percent, + ) + ) + + +def _period_suffix(data): + if not data.get("first_worked_on"): + return "" + if data.get("first_worked_on") == data.get("last_worked_on"): + return ", %s" % data["first_worked_on"] + return ", %s..%s" % (data["first_worked_on"], data["last_worked_on"]) diff --git a/src/claude_diary/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py index e8f4039..1c778b8 100644 --- a/src/claude_diary/exporters/notion_hierarchical.py +++ b/src/claude_diary/exporters/notion_hierarchical.py @@ -396,6 +396,24 @@ def archive_rows_for_session(self, db_id, session_id): notion_cache.invalidate_rows_for_session(self._cache, session_id) return archived + def query_database_rows(self, db_id, page_size=100): + """Return all rows in an Entries database without mutating Notion.""" + rows = [] + body = { + "page_size": page_size, + } + cursor = None + while True: + if cursor: + body["start_cursor"] = cursor + elif "start_cursor" in body: + del body["start_cursor"] + resp = self._request("POST", "/databases/%s/query" % db_id, dict(body)) + rows.extend(resp.get("results", [])) + if not resp.get("has_more"): + return rows + cursor = resp.get("next_cursor") + def create_row(self, db_id, properties, body_blocks): """Create a new row (page) in the database with properties + body blocks.""" body = { diff --git a/tests/test_notion_ensure.py b/tests/test_notion_ensure.py index 3550bb3..4af83f3 100644 --- a/tests/test_notion_ensure.py +++ b/tests/test_notion_ensure.py @@ -5,7 +5,11 @@ import pytest -from claude_diary.cli.notion_ensure import cmd_notion_ensure +from claude_diary.cli.notion_ensure import ( + cmd_notion_ensure, + build_conflict_plan, + classify_conflict_reason, +) from claude_diary.exporters.notion_hierarchical import SCHEMA_VERSION from claude_diary.exporters.notion_views import EnsureViewsResult, ViewConflict @@ -99,7 +103,7 @@ def test_dry_run_missing_database_prints_plan_without_writes(self, capsys): assert "+ create 5 core views" in captured.out assert "+ create 5 operating views" in captured.out - def test_conflict_exits_1(self): + def test_conflict_exits_1(self, capsys): exporter = MagicMock() exporter.ensure_database.return_value = "db1" result = EnsureViewsResult(conflicts=[ @@ -116,3 +120,44 @@ def test_conflict_exits_1(self): cmd_notion_ensure(_args(year=2026)) assert exc.value.code == 1 + captured = capsys.readouterr() + assert "conflict[missing_filter]" in captured.out + assert "rerun `working-diary diary-notion ensure` to repair the view filter" in captured.out + assert "apply: yes" in captured.out + + +class TestClassifyConflictReason: + def test_missing_filter(self): + assert classify_conflict_reason("missing Date=today filter") == "missing_filter" + + def test_missing_property(self): + assert classify_conflict_reason("missing property: Work Period") == "missing_property" + + def test_subitem_missing(self): + assert classify_conflict_reason("native Sub-items not enabled") == "subitem_missing" + + def test_permission_or_auth(self): + assert classify_conflict_reason("403 permission denied") == "permission_or_auth" + + def test_api_failure(self): + assert classify_conflict_reason("Notion API 400 failed") == "api_failure" + + +class TestBuildConflictPlan: + def test_missing_filter_is_apply_supported(self): + plan = build_conflict_plan("오늘 작업", "missing Date=today filter") + assert plan["category"] == "missing_filter" + assert plan["apply_supported"] is True + assert "repair the view filter" in plan["action"] + + def test_subitem_missing_is_manual(self): + plan = build_conflict_plan("warning", "native Sub-items not enabled") + assert plan["category"] == "subitem_missing" + assert plan["apply_supported"] is False + assert "enable Notion Sub-items" in plan["action"] + + def test_permission_is_manual(self): + plan = build_conflict_plan("작업 계층", "403 permission denied") + assert plan["category"] == "permission_or_auth" + assert plan["apply_supported"] is False + assert "refresh the token" in plan["action"] diff --git a/tests/test_notion_hierarchical.py b/tests/test_notion_hierarchical.py index 32ca85d..040f7d2 100644 --- a/tests/test_notion_hierarchical.py +++ b/tests/test_notion_hierarchical.py @@ -576,6 +576,37 @@ def test_archives_all_matching_rows(self, tmp_path): assert c.kwargs["json"] == {"archived": True} +class TestQueryDatabaseRows: + def test_paginates_all_rows(self): + exp = _make_exporter() + mock_req = MagicMock() + mock_req.request.side_effect = [ + _make_response(200, { + "results": [{"id": "row_a"}], + "has_more": True, + "next_cursor": "cursor-1", + }), + _make_response(200, { + "results": [{"id": "row_b"}], + "has_more": False, + }), + ] + + with _patch_requests(mock_req): + rows = exp.query_database_rows("db_xyz") + + assert rows == [{"id": "row_a"}, {"id": "row_b"}] + first = mock_req.request.call_args_list[0] + second = mock_req.request.call_args_list[1] + assert first.args[0] == "POST" + assert first.args[1].endswith("/databases/db_xyz/query") + assert first.kwargs["json"] == {"page_size": 100} + assert second.kwargs["json"] == { + "page_size": 100, + "start_cursor": "cursor-1", + } + + class TestCreateRow: def test_posts_to_pages_endpoint(self, tmp_path): exp = _make_exporter() diff --git a/tests/test_notion_ops.py b/tests/test_notion_ops.py new file mode 100644 index 0000000..c8e2768 --- /dev/null +++ b/tests/test_notion_ops.py @@ -0,0 +1,272 @@ +"""Tests for `working-diary diary-notion ops` CLI.""" + +from argparse import Namespace +from unittest.mock import MagicMock, patch + +import pytest + +from claude_diary.cli.notion_ops import build_ops_report, cmd_notion_ops + + +def _config(): + return { + "timezone_offset": 9, + "exporters": { + "notion_hierarchical": { + "api_token": "token", + "root_page_id": "root_page", + } + }, + } + + +def _args(year=2026, stale_days=7, json_output=False): + return Namespace(year=year, stale_days=stale_days, json_output=json_output) + + +def _row( + title, + row_id=None, + status="Implementation", + project="working-diary", + task_group="phase-3", + blocked=False, + review_status="Needs Review", + next_action="", + block_reason="", + work_period="2026-06-01", + parent_ids=None, + priority="P2", +): + if row_id is None: + row_id = "row-%s" % title + return { + "id": row_id, + "url": "https://notion.so/%s" % title, + "properties": { + "Name": {"title": [{"plain_text": title}]}, + "Status": {"select": {"name": status}}, + "Project": {"select": {"name": project}}, + "Task Group": {"select": {"name": task_group}}, + "Priority": {"select": {"name": priority}}, + "Blocked": {"checkbox": blocked}, + "Review Status": {"select": {"name": review_status}}, + "Next Action": {"rich_text": [{"plain_text": next_action}]}, + "Block Reason": {"rich_text": [{"plain_text": block_reason}]}, + "Work Period": {"date": {"start": work_period}}, + "Parent Task": {"relation": [{"id": pid} for pid in (parent_ids or [])]}, + }, + } + + +class TestBuildOpsReport: + def test_counts_operations_signals(self): + rows = [ + _row("blocked", blocked=True, block_reason="권한 필요"), + _row("missing-next", next_action="", review_status="Deferred"), + _row("review", next_action="검토 요청"), + _row("done", status="Deployed", review_status="Reviewed", work_period="2026-06-05"), + ] + + report = build_ops_report(rows, "2026-06-10", stale_days=7) + + assert report["counts"]["total"] == 4 + assert report["counts"]["active"] == 3 + assert report["counts"]["blocked"] == 1 + assert report["counts"]["needs_review"] == 2 + assert report["counts"]["missing_next_action"] == 2 + assert report["counts"]["stale"] == 3 + assert report["counts"]["verification_candidates"] == 2 + assert report["counts"]["today_plan_candidates"] == 1 + assert report["counts"]["parent_status_suggestions"] == 0 + assert report["today_plan_candidates"][0]["title"] == "review" + assert report["verification_candidates"][0]["reason"] + assert report["task_groups"]["phase-3"]["done"] == 1 + assert report["task_groups"]["phase-3"]["active"] == 3 + assert report["task_groups"]["phase-3"]["work_days"] == 2 + assert report["task_groups"]["phase-3"]["done_ratio"] == 0.25 + assert report["task_groups"]["phase-3"]["first_worked_on"] == "2026-06-01" + assert report["task_groups"]["phase-3"]["last_worked_on"] == "2026-06-05" + assert report["projects"]["working-diary"]["total"] == 4 + + def test_archived_rows_are_ignored(self): + row = _row("archived") + row["archived"] = True + + report = build_ops_report([row], "2026-06-10") + + assert report["counts"]["total"] == 0 + + def test_parent_status_suggestion_when_children_done(self): + rows = [ + _row("parent", row_id="parent-row", status="Testing"), + _row("child-a", status="Deployed", parent_ids=["parent-row"]), + _row("child-b", status="Deployed", parent_ids=["parent-row"]), + ] + + report = build_ops_report(rows, "2026-06-10") + + assert report["counts"]["parent_status_suggestions"] == 1 + suggestion = report["parent_status_suggestions"][0] + assert suggestion["title"] == "parent" + assert suggestion["suggested_status"] == "Deployed" + assert suggestion["child_count"] == 2 + progress = report["parent_progress"][0] + assert progress["title"] == "parent" + assert progress["children"] == 2 + assert progress["done"] == 2 + assert progress["done_ratio"] == 1.0 + + def test_parent_progress_counts_active_and_blocked_children(self): + rows = [ + _row("parent", row_id="parent-row", status="Implementation"), + _row("child-a", status="Deployed", parent_ids=["parent-row"]), + _row("child-b", status="Testing", parent_ids=["parent-row"], blocked=True), + ] + + report = build_ops_report(rows, "2026-06-10") + + progress = report["parent_progress"][0] + assert progress["children"] == 2 + assert progress["done"] == 1 + assert progress["active"] == 1 + assert progress["blocked"] == 1 + assert progress["done_ratio"] == 0.5 + + def test_parent_status_suggests_testing_when_child_reaches_testing(self): + rows = [ + _row("parent", row_id="parent-row", status="Implementation"), + _row("child", status="Testing", parent_ids=["parent-row"]), + ] + + report = build_ops_report(rows, "2026-06-10") + + suggestion = report["parent_status_suggestions"][0] + assert suggestion["suggested_status"] == "Testing" + assert suggestion["reason"] == "at least one child task is in Testing" + + def test_parent_status_suggests_implementation_from_design(self): + rows = [ + _row("parent", row_id="parent-row", status="Design"), + _row("child", status="Implementation", parent_ids=["parent-row"]), + ] + + report = build_ops_report(rows, "2026-06-10") + + suggestion = report["parent_status_suggestions"][0] + assert suggestion["suggested_status"] == "Implementation" + assert suggestion["reason"] == "at least one child task is in Implementation" + + def test_parent_status_reports_blocked_child_without_overwriting_status(self): + rows = [ + _row("parent", row_id="parent-row", status="Testing"), + _row("child", status="Implementation", blocked=True, parent_ids=["parent-row"]), + ] + + report = build_ops_report(rows, "2026-06-10") + + suggestion = report["parent_status_suggestions"][0] + assert suggestion["suggested_status"] == "Testing" + assert suggestion["reason"] == "at least one child task is blocked" + + def test_today_plan_candidates_sorted_by_priority_then_age(self): + rows = [ + _row("p2-old", next_action="계속", priority="P2", work_period="2026-06-01"), + _row("p1-new", next_action="먼저", priority="P1", work_period="2026-06-09"), + _row("blocked", next_action="대기", priority="P0", blocked=True), + _row("no-action", next_action="", priority="P0"), + ] + + report = build_ops_report(rows, "2026-06-10") + + assert report["counts"]["today_plan_candidates"] == 2 + assert [item["title"] for item in report["today_plan_candidates"]] == [ + "p1-new", + "p2-old", + ] + + def test_verification_candidates_include_reasons_and_sort_testing_first(self): + rows = [ + _row("implementation", status="Implementation", review_status="Deferred", priority="P0"), + _row("testing", status="Testing", next_action="", review_status="Needs Review", priority="P3"), + _row("reviewed", status="Testing", next_action="배포", review_status="Reviewed"), + _row("blocked", status="Testing", blocked=True, review_status="Needs Review"), + ] + + report = build_ops_report(rows, "2026-06-10") + + assert report["counts"]["verification_candidates"] == 2 + assert [item["title"] for item in report["verification_candidates"]] == [ + "testing", + "implementation", + ] + assert "testing_without_next_action" in report["verification_candidates"][0]["reason"] + assert "implementation_review_deferred" in report["verification_candidates"][1]["reason"] + + +class TestCmdNotionOps: + def test_missing_credentials_exits(self): + with patch("claude_diary.cli.notion_ops.load_config", return_value={}), \ + patch.dict("os.environ", {}, clear=True), \ + pytest.raises(SystemExit) as exc: + cmd_notion_ops(_args()) + + assert exc.value.code == 1 + + def test_missing_database_exits_without_creating(self, capsys): + exporter = MagicMock() + exporter.resolve_existing_database.return_value = None + + with patch("claude_diary.cli.notion_ops.load_config", return_value=_config()), \ + patch.dict("os.environ", {}, clear=True), \ + patch("claude_diary.cli.notion_ops.NotionHierarchicalExporter", return_value=exporter), \ + pytest.raises(SystemExit) as exc: + cmd_notion_ops(_args(year=2027)) + + assert exc.value.code == 1 + exporter.resolve_existing_database.assert_called_once_with(2027) + exporter.ensure_database.assert_not_called() + exporter.query_database_rows.assert_not_called() + captured = capsys.readouterr() + assert "Database: missing" in captured.out + + def test_prints_operations_report(self, capsys): + exporter = MagicMock() + exporter.resolve_existing_database.return_value = "db1" + exporter.query_database_rows.return_value = [ + _row("blocked", blocked=True, block_reason="토큰 확인"), + _row("done", status="Deployed", review_status="Reviewed", work_period="2026-06-05"), + ] + exporter.get_database_property_map.return_value = {} + + with patch("claude_diary.cli.notion_ops.load_config", return_value=_config()), \ + patch.dict("os.environ", {}, clear=True), \ + patch("claude_diary.cli.notion_ops.NotionHierarchicalExporter", return_value=exporter): + cmd_notion_ops(_args(year=2026, stale_days=3)) + + exporter.query_database_rows.assert_called_once_with("db1") + exporter.get_database_property_map.assert_called_once_with("db1") + captured = capsys.readouterr() + assert "[working-diary diary-notion ops]" in captured.out + assert "Rows: 2 total, 1 active" in captured.out + assert "Blocked: 1" in captured.out + assert "Today-plan candidates:" in captured.out + assert "Verification candidates:" in captured.out + assert "Task Groups:" in captured.out + assert "blocked: 토큰 확인" in captured.out + assert "Projects:" in captured.out + + def test_json_output(self, capsys): + exporter = MagicMock() + exporter.resolve_existing_database.return_value = "db1" + exporter.query_database_rows.return_value = [_row("row1")] + exporter.get_database_property_map.return_value = {} + + with patch("claude_diary.cli.notion_ops.load_config", return_value=_config()), \ + patch.dict("os.environ", {}, clear=True), \ + patch("claude_diary.cli.notion_ops.NotionHierarchicalExporter", return_value=exporter): + cmd_notion_ops(_args(json_output=True)) + + captured = capsys.readouterr() + assert '"counts"' in captured.out + assert '"row1"' in captured.out From 227cd3163d0ea2be88737c63e41932eb6208bfd2 Mon Sep 17 00:00:00 2001 From: cys Date: Fri, 5 Jun 2026 15:58:42 +0900 Subject: [PATCH 2/6] feat(notion): store verification artifacts in toggles --- skills/diary-notion/SKILL.md | 13 ++++++-- src/claude_diary/cli/setup.py | 20 ++++++++++-- src/claude_diary/formatter.py | 58 ++++++++++++++++++++++++++++++++++- src/claude_diary/i18n.py | 2 ++ tests/test_formatter.py | 40 ++++++++++++++++++++++++ tests/test_setup.py | 8 +++++ 6 files changed, 136 insertions(+), 5 deletions(-) diff --git a/skills/diary-notion/SKILL.md b/skills/diary-notion/SKILL.md index 86ce528..c93be9c 100644 --- a/skills/diary-notion/SKILL.md +++ b/skills/diary-notion/SKILL.md @@ -16,20 +16,23 @@ Split the current Codex session into task-sized entries and push them to Notion. - Use `Depends On` only for prerequisite links between large top-level main tasks. Never use dependencies for child tasks. - Never write `"unknown"` as `project`; omit it or leave it blank so the CLI falls back to the command cwd folder name. - Page bodies render as compact executive bodies: top summary, result checklist, work-at-a-glance table, impact, verification, risks/next action, and appendix. +- For testing, QA, review, validation, or verification sessions, create a row even without code changes; keep `verification` short and place the meaningful prompt-result document in `prompt_outputs` or `verification_artifacts` so it renders inside a toggle. ## 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 + - Create a row for verification-only work even when the session produced no file changes or commits - Keep tiny check items, raw notes, long SQL/JS snippets, and reference links inside the page body evidence instead of making them separate rows - Create a separate row only when the work has an independent status, verification/evidence, code change, commit, blocker, or follow-up owner + - For tester/verification sessions, summarize the final state in `verification` and put each meaningful pass, fail, blocker, skipped check, regression, defect, and follow-up in the prompt-output artifact fields - Use `parent_index` for containment hierarchy and Notion sub-items; do not model subtasks with dependencies - Use `depends_on_indices` only for prerequisite links between large top-level tasks - Mark continued work from an earlier day/session as a new row with the same `task_group` and `carryover=true` when it is still unfinished 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`, `support_needed`, `next_action`, and `block_reason` in Korean + - Write `title`, `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `prompt_outputs`, `verification_artifacts`, `risks`, `next_steps`, `support_needed`, `next_action`, and `block_reason` 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 @@ -41,6 +44,7 @@ Split the current Codex session into task-sized entries and push them to Notion. - Treat `summary_hints` as checked result items, not repeated callouts - Keep `work_context`, `work_scope`, `approach`, and `outcome` short because they render as a compact "work at a glance" table - Put final verification state in `verification`; move intermediate command history to appendix evidence + - For tester/verification sessions, keep `verification` to 1-3 summary items and put the full meaningful prompt-result document in `prompt_outputs` or `verification_artifacts`; summarize long raw logs instead of pasting them - Keep risks concise; multiple risks are combined into one warning callout - `summary_hints`: up to 3 outcome-focused result items 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 @@ -55,12 +59,15 @@ Split the current Codex session into task-sized entries and push them to Notion. - 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 + - `verification`: 0-3 summary tests/checks run, final results, or explicit reasons checks were not run + - `prompt_outputs`: 0-15 meaningful prompt-result items for tester/verification sessions; include distinct pass/fail/blocker/skipped/regression/defect/follow-up results, not raw logs + - `verification_artifacts`: 0-15 structured verification artifact items when the prompt output is better grouped as a generated test report or review document - `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` + - Use `Test` for tester, QA, validation, and verification-only sessions unless another enum is clearly more accurate - `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 - `priority`: one of `P0`, `P1`, `P2`, `P3`; use `P0` for urgent/blocking work, `P1` for today's highest priority, `P2` for normal follow-up, and `P3` for low priority - `next_action`: 0-1 concrete Korean action that can be started next @@ -96,6 +103,8 @@ Split the current Codex session into task-sized entries and push them to Notion. "decisions": ["..."], "implementation_notes": ["..."], "verification": ["..."], + "prompt_outputs": ["..."], + "verification_artifacts": ["..."], "risks": ["..."], "next_steps": ["..."], "support_needed": ["..."], diff --git a/src/claude_diary/cli/setup.py b/src/claude_diary/cli/setup.py index ba470f2..e038898 100644 --- a/src/claude_diary/cli/setup.py +++ b/src/claude_diary/cli/setup.py @@ -33,6 +33,13 @@ # /diary-notion +## Testing / Verification Sessions + +- If the session work is testing, QA, review, validation, or verification, create a Notion row for the verification work even when there were no code changes. +- Set `status` to `Testing` and `purpose` to `Test` for verification-only work unless a more specific enum is clearly better. +- Keep `verification` short. Put the meaningful prompt-result document in `prompt_outputs` or `verification_artifacts` so it renders inside a Notion toggle. +- Do not collapse important findings into a vague summary. Put distinct passed checks, failed checks, defects, regressions, blocked checks, skipped checks, and follow-up actions in the prompt-output artifact fields, or create a child task row with `parent_index` when the result needs its own status or owner. + 현재 세션의 transcript와 git 정보를 분석하여 Notion 업무일지 DB에 push. ## 현재 구현 계약 @@ -250,20 +257,23 @@ - Use `Depends On` only for prerequisite links between large top-level main tasks. Never use dependencies for child tasks. - Never write `"unknown"` as `project`; omit it or leave it blank so the CLI falls back to the command cwd folder name. - Page bodies render as compact executive bodies: top summary, result checklist, work-at-a-glance table, impact, verification, risks/next action, and appendix. +- For testing, QA, review, validation, or verification sessions, create a row even without code changes; keep `verification` short and place the meaningful prompt-result document in `prompt_outputs` or `verification_artifacts` so it renders inside a toggle. ## 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 + - Create a row for verification-only work even when the session produced no file changes or commits - Keep tiny check items, raw notes, long SQL/JS snippets, and reference links inside the page body evidence instead of making them separate rows - Create a separate row only when the work has an independent status, verification/evidence, code change, commit, blocker, or follow-up owner + - For tester/verification sessions, summarize the final state in `verification` and put each meaningful pass, fail, blocker, skipped check, regression, defect, and follow-up in the prompt-output artifact fields - Use `parent_index` for containment hierarchy and Notion sub-items; do not model subtasks with dependencies - Use `depends_on_indices` only for prerequisite links between large top-level tasks - Mark continued work from an earlier day/session as a new row with the same `task_group` and `carryover=true` when it is still unfinished 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`, `support_needed`, `next_action`, and `block_reason` in Korean + - Write `title`, `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `prompt_outputs`, `verification_artifacts`, `risks`, `next_steps`, `support_needed`, `next_action`, and `block_reason` 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 @@ -275,6 +285,7 @@ - Treat `summary_hints` as checked result items, not repeated callouts - Keep `work_context`, `work_scope`, `approach`, and `outcome` short because they render as a compact "work at a glance" table - Put final verification state in `verification`; move intermediate command history to appendix evidence + - For tester/verification sessions, keep `verification` to 1-3 summary items and put the full meaningful prompt-result document in `prompt_outputs` or `verification_artifacts`; summarize long raw logs instead of pasting them - Keep risks concise; multiple risks are combined into one warning callout - `summary_hints`: up to 3 outcome-focused result items 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 @@ -289,12 +300,15 @@ - 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 + - `verification`: 0-3 summary tests/checks run, final results, or explicit reasons checks were not run + - `prompt_outputs`: 0-15 meaningful prompt-result items for tester/verification sessions; include distinct pass/fail/blocker/skipped/regression/defect/follow-up results, not raw logs + - `verification_artifacts`: 0-15 structured verification artifact items when the prompt output is better grouped as a generated test report or review document - `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` + - Use `Test` for tester, QA, validation, and verification-only sessions unless another enum is clearly more accurate - `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 - `priority`: one of `P0`, `P1`, `P2`, `P3`; use `P0` for urgent/blocking work, `P1` for today's highest priority, `P2` for normal follow-up, and `P3` for low priority - `next_action`: 0-1 concrete Korean action that can be started next @@ -330,6 +344,8 @@ "decisions": ["..."], "implementation_notes": ["..."], "verification": ["..."], + "prompt_outputs": ["..."], + "verification_artifacts": ["..."], "risks": ["..."], "next_steps": ["..."], "support_needed": ["..."], diff --git a/src/claude_diary/formatter.py b/src/claude_diary/formatter.py index a5aabd5..7107797 100644 --- a/src/claude_diary/formatter.py +++ b/src/claude_diary/formatter.py @@ -6,6 +6,10 @@ from claude_diary.i18n import get_label +DEFAULT_VERIFICATION_LIMIT = 3 +PROMPT_OUTPUT_LIMIT = 15 + + def format_entry(entry_data, lang="ko"): """Format entry_data into a markdown diary entry.""" L = lambda key: get_label(key, lang) @@ -181,7 +185,7 @@ def _add_snapshot_section(blocks, task, L): def _add_verification_section(blocks, task, L): section_blocks = [] - for v in _limited_texts(task.get("verification"), 3): + for v in _limited_texts(task.get("verification"), DEFAULT_VERIFICATION_LIMIT): section_blocks.append(_to_do(v, checked=True)) if section_blocks: blocks.append(_heading(L("verification"))) @@ -204,9 +208,12 @@ def _add_risks_next_actions_section(blocks, task, L): def _build_appendix_blocks(task, git_info, L): blocks = [] + prompt_outputs = _build_prompt_output_items(task) developer = _build_developer_evidence_items(task, git_info, L) raw = _build_raw_evidence_items(task, L) + if prompt_outputs: + blocks.append(_toggle(L("prompt_outputs"), [_bullet(item) for item in prompt_outputs])) if developer: blocks.append(_toggle(L("developer_evidence"), [_bullet(item) for item in developer])) if raw: @@ -279,6 +286,55 @@ def _significant_commands(commands): return sig +def _build_prompt_output_items(task): + items = [] + if _is_verification_session_task(task): + items.extend(_as_text_list(task.get("verification"))) + for field in ( + "prompt_outputs", + "verification_artifacts", + "test_results", + "validation_results", + "findings", + ): + items.extend(_as_text_list(task.get(field))) + return _dedupe_texts(_limited_texts(items, PROMPT_OUTPUT_LIMIT, limit=1000)) + + +def _is_verification_session_task(task): + tokens = [ + task.get("status"), + task.get("purpose"), + task.get("task_group"), + ] + tokens.extend(_as_text_list(task.get("categories"))) + haystack = " ".join(str(token or "").lower() for token in tokens) + keywords = ( + "testing", + "test", + "qa", + "verify", + "verification", + "validation", + "review", + "검증", + "테스트", + "테스터", + ) + return any(keyword in haystack for keyword in keywords) + + +def _dedupe_texts(items): + seen = set() + result = [] + for item in items: + if item in seen: + continue + seen.add(item) + result.append(item) + return result + + def _labeled_texts(label, items, max_items, limit=500): return ["%s: %s" % (label, item) for item in _limited_texts(items, max_items, limit)] diff --git a/src/claude_diary/i18n.py b/src/claude_diary/i18n.py index 1795d1b..c3cbaa7 100644 --- a/src/claude_diary/i18n.py +++ b/src/claude_diary/i18n.py @@ -39,6 +39,7 @@ "risks_next_steps": "리스크 및 다음 단계", "evidence": "원자료", "appendix": "부록", + "prompt_outputs": "프롬프트 결과물 / 검증 산출물", "developer_evidence": "개발 근거", "raw_evidence": "원문 요청", "issues": "발생한 이슈", @@ -111,6 +112,7 @@ "risks_next_steps": "Risks and Next Steps", "evidence": "Evidence", "appendix": "Appendix", + "prompt_outputs": "Prompt Outputs / Verification Artifacts", "developer_evidence": "Developer Evidence", "raw_evidence": "Original Requests", "issues": "Issues Encountered", diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 70f7d4d..ee080cf 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -193,6 +193,46 @@ def test_rich_notion_body_limits_summary_bullets(self): assert "summary-3" not in texts assert [b["type"] for b in blocks[:4]] == ["heading_2", "to_do", "to_do", "to_do"] + def test_testing_tasks_move_full_verification_results_to_prompt_output_toggle(self): + blocks = build_notion_blocks({ + "status": "Testing", + "purpose": "Test", + "categories": ["qa"], + "verification": ["result-%d" % i for i in range(8)], + }, lang="en") + texts = [_block_text(b) for b in _flatten_blocks(blocks)] + todos = [_block_text(b) for b in _flatten_blocks(blocks) if b["type"] == "to_do"] + bullets = [_block_text(b) for b in _flatten_blocks(blocks) if b["type"] == "bulleted_list_item"] + + assert "Prompt Outputs / Verification Artifacts" in texts + assert "result-0" in texts + assert "result-2" in todos + assert "result-3" not in todos + assert "result-7" in bullets + + def test_default_tasks_still_limit_verification_results(self): + blocks = build_notion_blocks({ + "status": "Implementation", + "purpose": "Feature", + "verification": ["verify-%d" % i for i in range(8)], + }, lang="en") + texts = [_block_text(b) for b in _flatten_blocks(blocks)] + + assert "verify-2" in texts + assert "verify-3" not in texts + + def test_verification_aliases_render_for_qa_tasks(self): + blocks = build_notion_blocks({ + "categories": ["validation"], + "test_results": ["suite passed"], + "findings": ["missing error handling"], + }, lang="en") + texts = [_block_text(b) for b in _flatten_blocks(blocks)] + + assert "Prompt Outputs / Verification Artifacts" in texts + assert "suite passed" in texts + assert "missing error handling" in texts + def test_work_snapshot_uses_table_not_callouts(self): blocks = build_notion_blocks({ "work_context": "Started from UX feedback.", diff --git a/tests/test_setup.py b/tests/test_setup.py index c3b45bb..907b029 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -67,6 +67,8 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): "decisions", "implementation_notes", "verification", + "prompt_outputs", + "verification_artifacts", "risks", "next_steps", "support_needed", @@ -96,11 +98,17 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): assert "schema v7" in codex_content assert "native sub-items" in codex_content assert "Never write `\"unknown\"` as `project`" in codex_content + assert "testing, QA, review, validation, or verification sessions" in codex_content + assert "verification-only work" in codex_content + assert "prompt-result document" in codex_content + assert "prompt_outputs" in codex_content # Claude /diary-notion slash command carries the implementation contract assert "현재 구현 계약" in DIARY_NOTION_SLASH_COMMAND assert "schema v7" in DIARY_NOTION_SLASH_COMMAND assert "native 하위항목" in DIARY_NOTION_SLASH_COMMAND assert "carryover=true" in DIARY_NOTION_SLASH_COMMAND + assert "Testing / Verification Sessions" in DIARY_NOTION_SLASH_COMMAND + assert "prompt-output artifact fields" in DIARY_NOTION_SLASH_COMMAND class TestInstallSlashCommandSingle: From 22574711c3ab329a095351ffc1b81fc52c9dfde1 Mon Sep 17 00:00:00 2001 From: cys Date: Fri, 5 Jun 2026 17:01:06 +0900 Subject: [PATCH 3/6] feat(notion): add v2 work report operations --- skills/diary-notion/SKILL.md | 85 +++++---- src/claude_diary/cli/__init__.py | 2 +- src/claude_diary/cli/notion_push.py | 156 ++++++++++++++-- src/claude_diary/cli/setup.py | 96 ++++++---- src/claude_diary/formatter.py | 275 ++++++++++++++++++++++------ src/claude_diary/i18n.py | 16 ++ tests/test_codex_plugin.py | 11 +- tests/test_formatter.py | 116 +++++++++--- tests/test_notion_push.py | 114 ++++++++++++ tests/test_setup.py | 10 +- 10 files changed, 713 insertions(+), 168 deletions(-) diff --git a/skills/diary-notion/SKILL.md b/skills/diary-notion/SKILL.md index c93be9c..8d48a92 100644 --- a/skills/diary-notion/SKILL.md +++ b/skills/diary-notion/SKILL.md @@ -15,7 +15,8 @@ Split the current Codex session into task-sized entries and push them to Notion. - Treat legacy `Parent Task` / `Sub-items` as compatibility data only. Do not target them directly in JSON. - Use `Depends On` only for prerequisite links between large top-level main tasks. Never use dependencies for child tasks. - Never write `"unknown"` as `project`; omit it or leave it blank so the CLI falls back to the command cwd folder name. -- Page bodies render as compact executive bodies: top summary, result checklist, work-at-a-glance table, impact, verification, risks/next action, and appendix. +- Page bodies render as compact work reports: summary, results, work table, decisions, issues/risks, next actions/support, and appendix toggles. +- Notion is the report surface; raw logs, long diffs, and bulky evidence belong in local artifact files and should be referenced by path/hash instead of pasted. - For testing, QA, review, validation, or verification sessions, create a row even without code changes; keep `verification` short and place the meaningful prompt-result document in `prompt_outputs` or `verification_artifacts` so it renders inside a toggle. ## Workflow @@ -31,8 +32,10 @@ Split the current Codex session into task-sized entries and push them to Notion. - Use `depends_on_indices` only for prerequisite links between large top-level tasks - Mark continued work from an earlier day/session as a new row with the same `task_group` and `carryover=true` when it is still unfinished 3. For each task, produce: + - Prefer schema v2 normalized fields: `summary`, `work`, `decisions`, `risks`, `next_actions`, `support_needed`, and `appendix`. + - Legacy flat fields are still accepted by the CLI, but new agent output should use v2 unless compatibility with an older installed version is required. - Language policy: - - Write `title`, `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `prompt_outputs`, `verification_artifacts`, `risks`, `next_steps`, `support_needed`, `next_action`, and `block_reason` in Korean + - Write `title`, `summary`, `work`, `decisions`, `risks`, `next_actions`, `support_needed`, `appendix`, `next_action`, and `block_reason` narrative values 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 @@ -40,12 +43,13 @@ Split the current Codex session into task-sized entries and push them to Notion. - `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 - Body rendering policy: - - Use `body_intro` as the only top summary callout - - Treat `summary_hints` as checked result items, not repeated callouts - - Keep `work_context`, `work_scope`, `approach`, and `outcome` short because they render as a compact "work at a glance" table - - Put final verification state in `verification`; move intermediate command history to appendix evidence - - For tester/verification sessions, keep `verification` to 1-3 summary items and put the full meaningful prompt-result document in `prompt_outputs` or `verification_artifacts`; summarize long raw logs instead of pasting them - - Keep risks concise; multiple risks are combined into one warning callout + - Use `summary.intro` as the only top summary callout + - Treat `summary.outcomes`, `summary.verification`, and `summary.remaining` as the compact results checklist + - Keep `work.context`, `work.scope`, `work.approach`, and `work.state` short because they render as a compact work table + - Put settled choices in `decisions`; put unresolved external asks in `support_needed` + - Keep `risks` concise as issue/risk bullets, not raw logs + - Put developer evidence, prompt outputs, original requests, and command/file/commit evidence in `appendix` toggles + - For tester/verification sessions, keep `summary.verification` to 1-3 summary items and put the meaningful prompt-result document in `appendix.prompt_outputs` or `appendix.verification_artifacts`; summarize long raw logs instead of pasting them - `summary_hints`: up to 3 outcome-focused result items 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 @@ -62,6 +66,7 @@ Split the current Codex session into task-sized entries and push them to Notion. - `verification`: 0-3 summary tests/checks run, final results, or explicit reasons checks were not run - `prompt_outputs`: 0-15 meaningful prompt-result items for tester/verification sessions; include distinct pass/fail/blocker/skipped/regression/defect/follow-up results, not raw logs - `verification_artifacts`: 0-15 structured verification artifact items when the prompt output is better grouped as a generated test report or review document + - `appendix.artifacts`: 0-5 local artifact references with `path`, `kind`, `summary`, and optional `sha256`; use this for stdout/stderr/diff/raw-log evidence - `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 @@ -88,26 +93,46 @@ Split the current Codex session into task-sized entries and push them to Notion. ```json { "session_id": "", + "schema_version": 2, "tasks": [ { "title": "...", - "body_intro": "...", - "summary_hints": ["..."], - "key_changes": ["..."], - "work_context": ["..."], - "work_scope": ["..."], - "approach": ["..."], - "outcome": ["..."], - "impact": ["..."], - "code_change_highlights": ["..."], + "summary": { + "intro": "...", + "outcomes": ["..."], + "verification": ["..."], + "remaining": ["..."] + }, + "work": { + "context": "...", + "scope": "...", + "approach": "...", + "state": "..." + }, "decisions": ["..."], - "implementation_notes": ["..."], - "verification": ["..."], - "prompt_outputs": ["..."], - "verification_artifacts": ["..."], "risks": ["..."], - "next_steps": ["..."], + "next_actions": ["..."], "support_needed": ["..."], + "appendix": { + "key_changes": ["..."], + "implementation_notes": ["..."], + "prompt_outputs": ["..."], + "verification_artifacts": ["..."], + "user_prompts": ["..."], + "files_modified": ["..."], + "files_created": ["..."], + "commands_run": ["..."], + "commit_hashes": ["..."], + "errors": ["..."], + "artifacts": [ + { + "kind": "stdout", + "path": ".codefleet/runs//stdout.log", + "summary": "raw command output", + "sha256": "..." + } + ] + }, "status": "Implementation", "purpose": "Feature", "work_period": "2026-06-02", @@ -122,20 +147,16 @@ Split the current Codex session into task-sized entries and push them to Notion. "parent_index": null, "depends_on_indices": [], "categories": ["feature"], - "project": "", - "user_prompts": ["..."], - "files_modified": ["..."], - "files_created": ["..."], - "commands_run": ["..."], - "commit_hashes": ["..."], - "errors": ["..."] + "project": "" } ] } ``` -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. +5. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json --dry-run` to preview the compact report body and appendix toggles without writing to Notion. +6. If the preview is structurally wrong, fix the JSON before pushing. +7. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json`. +8. If `working-diary` is not available, run `claude-diary diary-notion push --input .diary-notion-<8-random>.json`. +9. 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 be96b86..2cbc289 100644 --- a/src/claude_diary/cli/__init__.py +++ b/src/claude_diary/cli/__init__.py @@ -46,7 +46,7 @@ def _add_diary_notion_parser(sub, name): help="Archive prior rows for the session before pushing (push only)") p_notion.add_argument("--year", type=int, help="Target year (ensure/ops only)") p_notion.add_argument("--dry-run", action="store_true", - help="Print the Notion schema/view plan without writing (ensure only)") + help="Preview push body without writing, or print ensure plan") p_notion.add_argument("--stale-days", type=int, default=7, help="Mark active rows stale after N days (ops only)") p_notion.add_argument("--json", dest="json_output", action="store_true", diff --git a/src/claude_diary/cli/notion_push.py b/src/claude_diary/cli/notion_push.py index 3d33b64..3dfeb54 100644 --- a/src/claude_diary/cli/notion_push.py +++ b/src/claude_diary/cli/notion_push.py @@ -41,11 +41,7 @@ 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) + dry_run = getattr(args, "dry_run", False) is True input_path = args.input data = _read_json(input_path) @@ -56,7 +52,8 @@ def cmd_notion_push(args): tasks = data.get("tasks") or [] if not tasks: print("[claude-diary diary-notion push] No tasks to push.") - _cleanup(input_path) + if not dry_run: + _cleanup(input_path) return cwd = os.getcwd() @@ -71,6 +68,15 @@ def cmd_notion_push(args): year = today.year date_str = today.strftime("%Y-%m-%d") + if dry_run: + _print_dry_run_preview(tasks, session_id, date_str, cwd, lang) + return + + token, root_page_id = _resolve_credentials(config) + if not token or not root_page_id: + _print_setup_hint() + sys.exit(1) + exporter = NotionHierarchicalExporter({ "api_token": token, "root_page_id": root_page_id, @@ -296,7 +302,7 @@ def _push_task(exporter, year, date_str, session_id, task_index, task, cwd, lang if existing: return "skipped", existing - git_info = _gather_git_info(cwd, task.get("commit_hashes") or []) + git_info = _gather_git_info(cwd, _task_commit_hashes(task)) branch = git_info.get("branch") or "" properties = _build_properties( @@ -376,10 +382,7 @@ def _build_properties(task, date_str, branch, git_info, session_id, task_index, 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 []) - ) + files_count = _task_files_count(task) lines = (stat.get("added", 0) or 0) + (stat.get("deleted", 0) or 0) props = { @@ -412,7 +415,7 @@ def _build_properties(task, date_str, branch, git_info, session_id, task_index, if priority: props["Priority"] = {"select": {"name": priority}} - next_action = _clean_rich_text(task.get("next_action")) + next_action = _clean_rich_text(_task_next_action(task)) if next_action: props["Next Action"] = {"rich_text": [{"text": {"content": next_action}}]} @@ -539,6 +542,135 @@ def _clean_rich_text(value): return raw[:2000] if raw else "" +def _task_appendix(task): + value = task.get("appendix") + return value if isinstance(value, dict) else {} + + +def _task_texts(*values): + items = [] + for value in values: + if value is None: + continue + if isinstance(value, str): + candidates = [value] + elif isinstance(value, (list, tuple, set)): + candidates = value + else: + candidates = [value] + for item in candidates: + text = str(item or "").replace("\n", " ").strip() + if text: + items.append(text) + return _dedupe_texts(items) + + +def _dedupe_texts(items): + seen = set() + result = [] + for item in items: + if item in seen: + continue + seen.add(item) + result.append(item) + return result + + +def _task_commit_hashes(task): + appendix = _task_appendix(task) + return _task_texts(appendix.get("commit_hashes"), task.get("commit_hashes")) + + +def _task_files_count(task): + appendix = _task_appendix(task) + modified = _task_texts(appendix.get("files_modified"), task.get("files_modified")) + created = _task_texts(appendix.get("files_created"), task.get("files_created")) + return len(modified) + len(created) + + +def _task_next_action(task): + flat = task.get("next_action") + if flat: + return flat + actions = _task_texts(task.get("next_actions"), task.get("next_steps")) + return actions[0] if actions else "" + + +def _print_dry_run_preview(tasks, session_id, date_str, cwd, lang): + print("[claude-diary diary-notion push --dry-run]") + print("Session ID: %s" % session_id) + print("Tasks: %d" % len(tasks)) + for idx, task in enumerate(tasks): + title = task.get("title") or "(untitled)" + git_info = _gather_git_info(cwd, _task_commit_hashes(task)) + branch = git_info.get("branch") or "" + props = _build_properties(task, date_str, branch, git_info, session_id, idx, cwd) + blocks = build_notion_blocks(task, git_info, lang) + print("") + print("[%d] %s" % (idx, title)) + print(" Project: %s" % props["Project"]["select"]["name"]) + print(" Purpose: %s" % props["Purpose"]["select"]["name"]) + print(" Files: %d" % props["Files"]["number"]) + print(" Commits: %d" % props["Commits"]["number"]) + print(" Lines: %d" % props["Lines"]["number"]) + if branch: + print(" Branch: %s" % branch) + for line in _blocks_to_preview_lines(blocks): + print(" %s" % line) + + +def _blocks_to_preview_lines(blocks, max_lines=80): + lines = [] + for block in blocks: + block_type = block.get("type") + if block_type == "heading_2": + lines.append("## %s" % _block_text(block, "heading_2")) + elif block_type == "callout": + lines.append("[Callout] %s" % _block_text(block, "callout")) + elif block_type == "to_do": + checked = "x" if block.get("to_do", {}).get("checked") else " " + lines.append("- [%s] %s" % (checked, _block_text(block, "to_do"))) + elif block_type == "bulleted_list_item": + lines.append("- %s" % _block_text(block, "bulleted_list_item")) + elif block_type == "toggle": + lines.append("> %s" % _block_text(block, "toggle")) + for child in block.get("toggle", {}).get("children", [])[:3]: + child_type = child.get("type") + if child_type: + lines.append(" - %s" % _block_text(child, child_type)) + elif block_type == "table": + lines.extend(_table_preview_lines(block)) + if len(lines) >= max_lines: + lines.append("... (%d more block lines)" % (len(blocks) - max_lines)) + break + return lines + + +def _table_preview_lines(block): + rows = [] + for child in block.get("table", {}).get("children", []): + cells = child.get("table_row", {}).get("cells", []) + row = " | ".join(_rich_text_plain(cell) for cell in cells) + if row.strip(): + rows.append(row) + return rows + + +def _block_text(block, block_type): + return _rich_text_plain(block.get(block_type, {}).get("rich_text", [])) + + +def _rich_text_plain(rich_text): + parts = [] + for item in rich_text or []: + text = item.get("plain_text") + if text is None: + text = (item.get("text") or {}).get("content") + if text: + parts.append(text) + return "".join(parts) + + def _print_report(results, input_path): pushed = len(results["pushed"]) skipped = len(results["skipped"]) diff --git a/src/claude_diary/cli/setup.py b/src/claude_diary/cli/setup.py index e038898..ade98e9 100644 --- a/src/claude_diary/cli/setup.py +++ b/src/claude_diary/cli/setup.py @@ -40,6 +40,14 @@ - Keep `verification` short. Put the meaningful prompt-result document in `prompt_outputs` or `verification_artifacts` so it renders inside a Notion toggle. - Do not collapse important findings into a vague summary. Put distinct passed checks, failed checks, defects, regressions, blocked checks, skipped checks, and follow-up actions in the prompt-output artifact fields, or create a child task row with `parent_index` when the result needs its own status or owner. +## Core Report Schema + +- Prefer schema v2 normalized fields: `summary`, `work`, `decisions`, `risks`, `next_actions`, `support_needed`, and `appendix`. +- Legacy flat fields are still accepted by the CLI, but new output should use v2. +- Notion is the report surface; raw logs, long diffs, and bulky evidence belong in local artifact files and should be referenced by path/hash instead of pasted. +- Page bodies render in this order: summary callout, results, work table, decisions, issues/risks, next actions/support, appendix toggles. +- Put command/file/commit evidence under `appendix`; use `appendix.artifacts` for stdout/stderr/diff/raw-log file references. + 현재 세션의 transcript와 git 정보를 분석하여 Notion 업무일지 DB에 push. ## 현재 구현 계약 @@ -138,7 +146,8 @@ 4. **JSON 저장 및 CLI 호출** - cwd 에 `.diary-notion-<8자리random>.json` 파일을 Write 도구로 생성 - - `!claude-diary diary-notion push --input .diary-notion-<8자리>.json` 실행 + - `!claude-diary diary-notion push --input .diary-notion-<8자리>.json --dry-run`으로 본문 구조를 먼저 확인 + - 구조가 맞으면 `!claude-diary diary-notion push --input .diary-notion-<8자리>.json` 실행 - 종료 후 임시 파일 삭제 (CLI도 try/finally로 삭제하지만 보험) ## JSON 형식 @@ -256,7 +265,8 @@ - Treat legacy `Parent Task` / `Sub-items` as compatibility data only. Do not target them directly in JSON. - Use `Depends On` only for prerequisite links between large top-level main tasks. Never use dependencies for child tasks. - Never write `"unknown"` as `project`; omit it or leave it blank so the CLI falls back to the command cwd folder name. -- Page bodies render as compact executive bodies: top summary, result checklist, work-at-a-glance table, impact, verification, risks/next action, and appendix. +- Page bodies render as compact work reports: summary, results, work table, decisions, issues/risks, next actions/support, and appendix toggles. +- Notion is the report surface; raw logs, long diffs, and bulky evidence belong in local artifact files and should be referenced by path/hash instead of pasted. - For testing, QA, review, validation, or verification sessions, create a row even without code changes; keep `verification` short and place the meaningful prompt-result document in `prompt_outputs` or `verification_artifacts` so it renders inside a toggle. ## Workflow @@ -272,8 +282,10 @@ - Use `depends_on_indices` only for prerequisite links between large top-level tasks - Mark continued work from an earlier day/session as a new row with the same `task_group` and `carryover=true` when it is still unfinished 3. For each task, produce: + - Prefer schema v2 normalized fields: `summary`, `work`, `decisions`, `risks`, `next_actions`, `support_needed`, and `appendix`. + - Legacy flat fields are still accepted by the CLI, but new agent output should use v2 unless compatibility with an older installed version is required. - Language policy: - - Write `title`, `body_intro`, `summary_hints`, `key_changes`, `work_context`, `work_scope`, `approach`, `outcome`, `impact`, `decisions`, `implementation_notes`, `verification`, `prompt_outputs`, `verification_artifacts`, `risks`, `next_steps`, `support_needed`, `next_action`, and `block_reason` in Korean + - Write `title`, `summary`, `work`, `decisions`, `risks`, `next_actions`, `support_needed`, `appendix`, `next_action`, and `block_reason` narrative values 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 @@ -281,12 +293,13 @@ - `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 - Body rendering policy: - - Use `body_intro` as the only top summary callout - - Treat `summary_hints` as checked result items, not repeated callouts - - Keep `work_context`, `work_scope`, `approach`, and `outcome` short because they render as a compact "work at a glance" table - - Put final verification state in `verification`; move intermediate command history to appendix evidence - - For tester/verification sessions, keep `verification` to 1-3 summary items and put the full meaningful prompt-result document in `prompt_outputs` or `verification_artifacts`; summarize long raw logs instead of pasting them - - Keep risks concise; multiple risks are combined into one warning callout + - Use `summary.intro` as the only top summary callout + - Treat `summary.outcomes`, `summary.verification`, and `summary.remaining` as the compact results checklist + - Keep `work.context`, `work.scope`, `work.approach`, and `work.state` short because they render as a compact work table + - Put settled choices in `decisions`; put unresolved external asks in `support_needed` + - Keep `risks` concise as issue/risk bullets, not raw logs + - Put developer evidence, prompt outputs, original requests, and command/file/commit evidence in `appendix` toggles + - For tester/verification sessions, keep `summary.verification` to 1-3 summary items and put the meaningful prompt-result document in `appendix.prompt_outputs` or `appendix.verification_artifacts`; summarize long raw logs instead of pasting them - `summary_hints`: up to 3 outcome-focused result items 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 @@ -303,6 +316,7 @@ - `verification`: 0-3 summary tests/checks run, final results, or explicit reasons checks were not run - `prompt_outputs`: 0-15 meaningful prompt-result items for tester/verification sessions; include distinct pass/fail/blocker/skipped/regression/defect/follow-up results, not raw logs - `verification_artifacts`: 0-15 structured verification artifact items when the prompt output is better grouped as a generated test report or review document + - `appendix.artifacts`: 0-5 local artifact references with `path`, `kind`, `summary`, and optional `sha256`; use this for stdout/stderr/diff/raw-log evidence - `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 @@ -329,26 +343,46 @@ ```json { "session_id": "", + "schema_version": 2, "tasks": [ { "title": "...", - "body_intro": "...", - "summary_hints": ["..."], - "key_changes": ["..."], - "work_context": ["..."], - "work_scope": ["..."], - "approach": ["..."], - "outcome": ["..."], - "impact": ["..."], - "code_change_highlights": ["..."], + "summary": { + "intro": "...", + "outcomes": ["..."], + "verification": ["..."], + "remaining": ["..."] + }, + "work": { + "context": "...", + "scope": "...", + "approach": "...", + "state": "..." + }, "decisions": ["..."], - "implementation_notes": ["..."], - "verification": ["..."], - "prompt_outputs": ["..."], - "verification_artifacts": ["..."], "risks": ["..."], - "next_steps": ["..."], + "next_actions": ["..."], "support_needed": ["..."], + "appendix": { + "key_changes": ["..."], + "implementation_notes": ["..."], + "prompt_outputs": ["..."], + "verification_artifacts": ["..."], + "user_prompts": ["..."], + "files_modified": ["..."], + "files_created": ["..."], + "commands_run": ["..."], + "commit_hashes": ["..."], + "errors": ["..."], + "artifacts": [ + { + "kind": "stdout", + "path": ".codefleet/runs//stdout.log", + "summary": "raw command output", + "sha256": "..." + } + ] + }, "status": "Implementation", "purpose": "Feature", "work_period": "2026-06-02", @@ -363,21 +397,17 @@ "parent_index": null, "depends_on_indices": [], "categories": ["feature"], - "project": "", - "user_prompts": ["..."], - "files_modified": ["..."], - "files_created": ["..."], - "commands_run": ["..."], - "commit_hashes": ["..."], - "errors": ["..."] + "project": "" } ] } ``` -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. +5. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json --dry-run` to preview the compact report body and appendix toggles without writing to Notion. +6. If the preview is structurally wrong, fix the JSON before pushing. +7. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json`. +8. If `working-diary` is not available, run `claude-diary diary-notion push --input .diary-notion-<8-random>.json`. +9. 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/formatter.py b/src/claude_diary/formatter.py index 7107797..8a63372 100644 --- a/src/claude_diary/formatter.py +++ b/src/claude_diary/formatter.py @@ -8,6 +8,7 @@ DEFAULT_VERIFICATION_LIMIT = 3 PROMPT_OUTPUT_LIMIT = 15 +APPENDIX_ITEM_LIMIT = 10 def format_entry(entry_data, lang="ko"): @@ -136,18 +137,19 @@ def build_notion_blocks(task, git_info=None, lang="ko"): """ L = lambda key: get_label(key, lang) blocks = [] + normalized = normalize_notion_task(task) - intro = (task.get("body_intro") or "").strip() + intro = (normalized["summary"].get("intro") or "").strip() if intro: blocks.append(_callout(intro, "📌")) - _add_results_section(blocks, task, L) - _add_snapshot_section(blocks, task, L) - _add_bullet_section(blocks, L("impact"), task.get("impact"), 3) - _add_verification_section(blocks, task, L) - _add_risks_next_actions_section(blocks, task, L) + _add_results_section(blocks, normalized, L) + _add_work_report_section(blocks, normalized, L) + _add_bullet_section(blocks, L("decisions"), normalized.get("decisions"), 3) + _add_bullet_section(blocks, L("issues_risks"), normalized.get("risks"), 3) + _add_next_actions_support_section(blocks, normalized, L) - appendix = _build_appendix_blocks(task, git_info, L) + appendix = _build_appendix_blocks(normalized, git_info, L) if appendix: blocks.append(_heading(L("appendix"))) blocks.extend(appendix) @@ -155,23 +157,121 @@ def build_notion_blocks(task, git_info=None, lang="ko"): return blocks +def normalize_notion_task(task): + """Normalize legacy flat task JSON and v2 nested task JSON for rendering.""" + summary = _dict_value(task.get("summary")) + work = _dict_value(task.get("work")) + appendix = _dict_value(task.get("appendix")) + + verification = _merge_texts( + summary.get("verification"), + task.get("verification"), + ) + outcomes = _merge_texts( + summary.get("outcomes"), + summary.get("outcome"), + task.get("summary_hints"), + task.get("outcome"), + ) + next_actions = _merge_texts( + task.get("next_actions"), + task.get("next_steps"), + task.get("next_action"), + ) + risks = _merge_texts( + task.get("risks"), + task.get("cautions"), + task.get("errors"), + task.get("errors_encountered"), + ) + appendix_errors = _merge_texts( + appendix.get("errors"), + task.get("errors"), + task.get("errors_encountered"), + ) + prompt_outputs = _merge_texts( + appendix.get("prompt_outputs"), + task.get("prompt_outputs"), + task.get("test_results"), + task.get("validation_results"), + task.get("findings"), + ) + verification_artifacts = _merge_texts( + appendix.get("verification_artifacts"), + task.get("verification_artifacts"), + ) + if _is_verification_session_task(task): + prompt_outputs = _merge_texts(verification, prompt_outputs) + + return { + "summary": { + "intro": _first_text(summary.get("intro"), task.get("body_intro")), + "outcomes": outcomes, + "verification": verification, + "remaining": _merge_texts(summary.get("remaining"), task.get("remaining_work")), + }, + "work": { + "context": _first_text(work.get("context"), task.get("work_context"), task.get("context")), + "scope": _first_text(work.get("scope"), task.get("work_scope"), task.get("scope")), + "approach": _first_text(work.get("approach"), task.get("approach")), + "state": _first_text(work.get("state"), task.get("work_state"), task.get("outcome"), task.get("status")), + "highlights": _merge_texts(work.get("highlights"), task.get("impact")), + }, + "decisions": _merge_texts(task.get("decisions")), + "risks": risks, + "next_actions": next_actions, + "support_needed": _merge_texts(task.get("support_needed")), + "appendix": { + "key_changes": _merge_texts( + appendix.get("key_changes"), + task.get("key_changes"), + task.get("code_change_highlights"), + task.get("code_changes"), + ), + "implementation_notes": _merge_texts( + appendix.get("implementation_notes"), + task.get("implementation_notes"), + ), + "prompt_outputs": prompt_outputs, + "verification_artifacts": verification_artifacts, + "user_prompts": _merge_texts(appendix.get("user_prompts"), task.get("user_prompts")), + "files_modified": _merge_texts(appendix.get("files_modified"), task.get("files_modified")), + "files_created": _merge_texts(appendix.get("files_created"), task.get("files_created")), + "commands_run": _merge_texts(appendix.get("commands_run"), task.get("commands_run")), + "commit_hashes": _merge_texts(appendix.get("commit_hashes"), task.get("commit_hashes")), + "errors": appendix_errors, + "artifacts": appendix.get("artifacts") or task.get("artifacts") or [], + }, + "status": task.get("status"), + "purpose": task.get("purpose"), + "task_group": task.get("task_group"), + "categories": _as_text_list(task.get("categories")), + } + + def _add_results_section(blocks, task, L): - items = _limited_texts(task.get("summary_hints") or task.get("summary"), 3) - if not items: - items = _limited_texts(task.get("outcome"), 1) + summary = task.get("summary") or {} + outcome_items = _limited_texts(summary.get("outcomes"), 2) + verification_items = _limited_texts(summary.get("verification"), 1) + remaining_items = _limited_texts(summary.get("remaining"), 1) + items = [] + items.extend((item, True) for item in outcome_items) + items.extend((item, True) for item in verification_items) + items.extend((item, False) for item in remaining_items) if not items: return blocks.append(_heading(L("results"))) - for item in items: - blocks.append(_to_do(item, checked=True)) + for item, checked in items[:4]: + blocks.append(_to_do(item, checked=checked)) -def _add_snapshot_section(blocks, task, L): +def _add_work_report_section(blocks, task, L): + work = task.get("work") or {} 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")), + (L("context"), work.get("context")), + (L("scope"), work.get("scope")), + (L("approach"), work.get("approach")), + (L("work_state"), work.get("state")), ] rows = [[L("item"), L("content")]] for label, value in pairs: @@ -179,71 +279,71 @@ def _add_snapshot_section(blocks, task, L): if texts: rows.append([label, texts[0]]) if len(rows) > 1: - blocks.append(_heading(L("work_snapshot"))) + blocks.append(_heading(L("work_report"))) blocks.append(_table(rows)) -def _add_verification_section(blocks, task, L): +def _add_next_actions_support_section(blocks, task, L): section_blocks = [] - for v in _limited_texts(task.get("verification"), DEFAULT_VERIFICATION_LIMIT): - section_blocks.append(_to_do(v, checked=True)) - if section_blocks: - blocks.append(_heading(L("verification"))) - blocks.extend(section_blocks) - - -def _add_risks_next_actions_section(blocks, task, L): - section_blocks = [] - risks = _limited_texts(task.get("risks") or task.get("cautions"), 2) - if risks: - section_blocks.append(_callout("\n".join(risks), "⚠️")) - 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): + for n in _limited_texts(task.get("next_actions"), 3): + section_blocks.append(_to_do("%s: %s" % (L("next_actions"), n), checked=False)) + for s in _limited_texts(task.get("support_needed"), 2): section_blocks.append(_to_do("%s: %s" % (L("support_needed"), s), checked=False)) if section_blocks: - blocks.append(_heading(L("risks_next_actions"))) + blocks.append(_heading(L("next_actions_support"))) blocks.extend(section_blocks) def _build_appendix_blocks(task, git_info, L): blocks = [] prompt_outputs = _build_prompt_output_items(task) - developer = _build_developer_evidence_items(task, git_info, L) + developer = _build_developer_evidence_items(task, L) + command_file_commit = _build_command_file_commit_items(task, git_info, L) raw = _build_raw_evidence_items(task, L) - if prompt_outputs: - blocks.append(_toggle(L("prompt_outputs"), [_bullet(item) for item in prompt_outputs])) if developer: blocks.append(_toggle(L("developer_evidence"), [_bullet(item) for item in developer])) + if prompt_outputs: + blocks.append(_toggle(L("prompt_outputs"), [_bullet(item) for item in prompt_outputs])) if raw: blocks.append(_toggle(L("raw_evidence"), [_bullet(item) for item in raw])) + if command_file_commit: + blocks.append(_toggle( + L("command_file_commit_evidence"), + [_bullet(item) for item in command_file_commit], + )) return blocks -def _build_developer_evidence_items(task, git_info, L): +def _build_developer_evidence_items(task, L): evidence = [] + appendix = task.get("appendix") or {} + work = task.get("work") or {} - for c in _limited_texts(task.get("key_changes"), 3): + for c in _limited_texts(work.get("highlights"), 3): + evidence.append("%s: %s" % (L("work_highlights"), c)) + + for c in _limited_texts(appendix.get("key_changes"), APPENDIX_ITEM_LIMIT): 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 n in _limited_texts(appendix.get("implementation_notes"), APPENDIX_ITEM_LIMIT): + evidence.append("%s: %s" % (L("implementation_notes"), n)) - for d in _limited_texts(task.get("decisions"), 2): - evidence.append("%s: %s" % (L("decisions"), d)) + return evidence - 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")) +def _build_command_file_commit_items(task, git_info, L): + evidence = [] + appendix = task.get("appendix") or {} + + modified = _as_text_list(appendix.get("files_modified")) + created = _as_text_list(appendix.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")) + commands = _significant_commands(appendix.get("commands_run")) for c in commands[:3]: evidence.append("%s: %s" % (L("commands"), _truncate(c, 500))) @@ -262,20 +362,80 @@ def _build_developer_evidence_items(task, git_info, L): 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 h in _limited_texts(appendix.get("commit_hashes"), 3): + evidence.append("%s: %s" % (L("commit"), h)) + + errors = _as_text_list(appendix.get("errors")) for e in errors[:2]: evidence.append("%s: %s" % (L("issues"), _truncate(e, 500))) + for a in _format_artifacts(appendix.get("artifacts"))[:3]: + evidence.append("%s: %s" % (L("artifacts"), a)) + return evidence def _build_raw_evidence_items(task, L): raw = [] - for p in _limited_texts(task.get("user_prompts"), 3): + appendix = task.get("appendix") or {} + for p in _limited_texts(appendix.get("user_prompts"), 3): raw.append("%s: %s" % (L("task_requests"), p)) return raw +def _dict_value(value): + return value if isinstance(value, dict) else {} + + +def _first_text(*values): + for value in values: + for item in _as_text_list(value): + text = item.replace("\n", " ").strip() + if text: + return text + return "" + + +def _merge_texts(*values): + items = [] + for value in values: + items.extend(_as_text_list(value)) + return _dedupe_texts([item.replace("\n", " ").strip() for item in items if item and item.strip()]) + + +def _format_artifacts(value): + formatted = [] + if isinstance(value, dict): + artifacts = [value] + elif isinstance(value, (list, tuple)): + artifacts = value + else: + artifacts = _as_text_list(value) + for artifact in artifacts: + if isinstance(artifact, dict): + path = str(artifact.get("path") or "").strip() + kind = str(artifact.get("kind") or "").strip() + summary = str(artifact.get("summary") or "").strip() + sha = str(artifact.get("sha256") or artifact.get("hash") or "").strip() + parts = [] + if kind: + parts.append(kind) + if path: + parts.append(path) + text = ": ".join(parts) if parts else summary + if summary and text != summary: + text = "%s - %s" % (text, summary) + if sha: + text = "%s (sha256: %s)" % (text, sha[:12]) + if text: + formatted.append(text) + else: + text = str(artifact).strip() + if text: + formatted.append(text) + return formatted + + def _significant_commands(commands): trivial = {"ls", "pwd", "cat", "echo", "cd", "which", "type", "clear"} sig = [] @@ -287,17 +447,10 @@ def _significant_commands(commands): def _build_prompt_output_items(task): + appendix = task.get("appendix") or {} items = [] - if _is_verification_session_task(task): - items.extend(_as_text_list(task.get("verification"))) - for field in ( - "prompt_outputs", - "verification_artifacts", - "test_results", - "validation_results", - "findings", - ): - items.extend(_as_text_list(task.get(field))) + items.extend(_as_text_list(appendix.get("prompt_outputs"))) + items.extend(_as_text_list(appendix.get("verification_artifacts"))) return _dedupe_texts(_limited_texts(items, PROMPT_OUTPUT_LIMIT, limit=1000)) diff --git a/src/claude_diary/i18n.py b/src/claude_diary/i18n.py index c3cbaa7..212743b 100644 --- a/src/claude_diary/i18n.py +++ b/src/claude_diary/i18n.py @@ -12,6 +12,13 @@ "summary": "작업 요약", "brief_summary": "요약", "results": "결과", + "report_summary": "요약", + "work_report": "진행 업무", + "work_state": "상태", + "issues_risks": "이슈 / 리스크", + "next_actions_support": "다음 액션 / 지원 요청", + "command_file_commit_evidence": "명령 / 파일 / 커밋 근거", + "artifacts": "증적", "work_overview": "작업 개요", "work_details": "작업 상세", "work_snapshot": "작업 한눈에", @@ -22,6 +29,7 @@ "approach": "접근", "outcome": "결과", "impact": "영향", + "work_highlights": "작업 하이라이트", "key_changes": "주요 변경사항", "code_change_highlights": "주요 코드 변경", "decisions": "결정 / 트레이드오프", @@ -85,6 +93,13 @@ "summary": "Work Summary", "brief_summary": "Summary", "results": "Results", + "report_summary": "Summary", + "work_report": "Work", + "work_state": "State", + "issues_risks": "Issues / Risks", + "next_actions_support": "Next Actions / Support", + "command_file_commit_evidence": "Command / File / Commit Evidence", + "artifacts": "Artifacts", "work_overview": "Work Overview", "work_details": "Work Details", "work_snapshot": "Work Snapshot", @@ -95,6 +110,7 @@ "approach": "Approach", "outcome": "Outcome", "impact": "Impact", + "work_highlights": "Work Highlights", "key_changes": "Key Changes", "code_change_highlights": "Code Change Highlights", "decisions": "Decisions / Trade-offs", diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index 05ff918..ea6052a 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -111,14 +111,17 @@ 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 diary-notion push" in notion_text + assert '"schema_version": 2' in notion_text assert '"purpose": "Feature"' in notion_text - assert '"summary_hints": ["..."]' in notion_text + assert '"summary": {' in notion_text + assert '"outcomes": ["..."]' in notion_text + assert '"work": {' in notion_text + assert '"context": "..."' in notion_text + assert '"appendix": {' 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 '"artifacts": [' in notion_text assert '"risks": ["..."]' in notion_text assert '"support_needed": ["..."]' in notion_text assert '"work_period": "2026-06-02"' in notion_text diff --git a/tests/test_formatter.py b/tests/test_formatter.py index ee080cf..b717dd6 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -165,20 +165,19 @@ def test_rich_notion_body_sections(self): assert texts[0] == "Implemented richer Notion body content." assert "Results" in headings - assert "Work Snapshot" in headings - assert "Impact" in headings - assert "Verification" in headings - assert "Risks / Next Actions" in headings + assert "Work" in headings + assert "Decisions / Trade-offs" in headings + assert "Issues / Risks" in headings + assert "Next Actions / Support" 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 "Key Changes: `formatter.py`: renders high-signal code change bullets without full diff" in texts assert "pytest passed" in texts assert "Existing sessions need refreshed installed commands" in texts - assert "Next Steps: Install refreshed Codex skills" in texts + assert "Next Actions: Install refreshed Codex skills" in texts + assert "Decisions / Trade-offs" in texts assert "Developer Evidence" in texts assert "Original Requests" in texts assert texts.index("Results") < texts.index("Appendix") @@ -189,9 +188,75 @@ def test_rich_notion_body_limits_summary_bullets(self): }, lang="en") texts = [_block_text(b) for b in _flatten_blocks(blocks)] - assert "summary-2" in texts - assert "summary-3" not in texts - assert [b["type"] for b in blocks[:4]] == ["heading_2", "to_do", "to_do", "to_do"] + assert "summary-1" in texts + assert "summary-2" not in texts + assert [b["type"] for b in blocks[:3]] == ["heading_2", "to_do", "to_do"] + + def test_results_section_stays_compact(self): + blocks = build_notion_blocks({ + "summary": { + "outcomes": ["outcome-%d" % i for i in range(4)], + "verification": ["verify-%d" % i for i in range(3)], + "remaining": ["remaining-0", "remaining-1"], + }, + }, lang="en") + todos = [_block_text(b) for b in _flatten_blocks(blocks) if b["type"] == "to_do"] + + assert todos == ["outcome-0", "outcome-1", "verify-0", "remaining-0"] + + def test_normalized_schema_renders_report_and_artifact_toggles(self): + blocks = build_notion_blocks({ + "summary": { + "intro": "Report summary.", + "outcomes": ["Feature completed"], + "verification": ["pytest passed"], + "remaining": ["Publish refreshed skill"], + }, + "work": { + "context": "Started from reporting review.", + "scope": "Notion renderer.", + "approach": "Normalize fields before rendering.", + "state": "Implementation complete.", + "highlights": ["Short report body"], + }, + "decisions": ["Keep Notion as report surface"], + "risks": ["Raw logs should stay outside Notion"], + "next_actions": ["Run dry-run preview later"], + "support_needed": ["No external support"], + "appendix": { + "key_changes": ["Normalized task adapter"], + "implementation_notes": ["Flat fields remain compatible"], + "prompt_outputs": ["QA finding summary"], + "verification_artifacts": ["Skipped check documented"], + "user_prompts": ["Use compact report format"], + "files_modified": ["src/claude_diary/formatter.py"], + "commands_run": ["python -m pytest -q"], + "artifacts": [{ + "kind": "stdout", + "path": ".codefleet/runs/20260605-001/stdout.log", + "summary": "test output", + "sha256": "abcdef1234567890", + }], + }, + }, lang="en") + texts = [_block_text(b) for b in _flatten_blocks(blocks)] + + assert texts[0] == "Report summary." + assert "Feature completed" in texts + assert "pytest passed" in texts + assert "Publish refreshed skill" in texts + assert "Work" in texts + assert "State | Implementation complete." in texts + assert "Decisions / Trade-offs" in texts + assert "Keep Notion as report surface" in texts + assert "Issues / Risks" in texts + assert "Raw logs should stay outside Notion" in texts + assert "Next Actions / Support" in texts + assert "Prompt Outputs / Verification Artifacts" in texts + assert "QA finding summary" in texts + assert "Command / File / Commit Evidence" in texts + assert "Artifacts: stdout: .codefleet/runs/20260605-001/stdout.log - test output (sha256: abcdef123456)" in texts + assert "Work Highlights: Short report body" in texts def test_testing_tasks_move_full_verification_results_to_prompt_output_toggle(self): blocks = build_notion_blocks({ @@ -206,11 +271,11 @@ def test_testing_tasks_move_full_verification_results_to_prompt_output_toggle(se assert "Prompt Outputs / Verification Artifacts" in texts assert "result-0" in texts - assert "result-2" in todos - assert "result-3" not in todos + assert todos == ["result-0"] + assert "result-2" in bullets assert "result-7" in bullets - def test_default_tasks_still_limit_verification_results(self): + def test_default_tasks_keep_one_verification_result_in_summary(self): blocks = build_notion_blocks({ "status": "Implementation", "purpose": "Feature", @@ -218,8 +283,8 @@ def test_default_tasks_still_limit_verification_results(self): }, lang="en") texts = [_block_text(b) for b in _flatten_blocks(blocks)] - assert "verify-2" in texts - assert "verify-3" not in texts + assert "verify-0" in texts + assert "verify-1" not in texts def test_verification_aliases_render_for_qa_tasks(self): blocks = build_notion_blocks({ @@ -233,7 +298,7 @@ def test_verification_aliases_render_for_qa_tasks(self): assert "suite passed" in texts assert "missing error handling" in texts - def test_work_snapshot_uses_table_not_callouts(self): + def test_work_report_uses_table_not_callouts(self): blocks = build_notion_blocks({ "work_context": "Started from UX feedback.", "work_scope": "Changed the Notion body renderer.", @@ -243,13 +308,14 @@ def test_work_snapshot_uses_table_not_callouts(self): flat = list(_flatten_blocks(blocks)) texts = [_block_text(b) for b in flat] - assert "Work Snapshot" in texts + assert "Work" in texts assert any(b["type"] == "table" for b in blocks) assert "Item | Content" in texts assert "Context | Started from UX feedback." in texts + assert "State | The page scans faster." in texts assert not any(b["type"] == "callout" for b in blocks) - def test_callout_count_is_limited_to_intro_and_combined_risks(self): + def test_callout_count_is_limited_to_intro_and_risks_are_bullets(self): blocks = build_notion_blocks({ "body_intro": "핵심 요약.", "summary_hints": ["결과 1", "결과 2"], @@ -260,20 +326,22 @@ def test_callout_count_is_limited_to_intro_and_combined_risks(self): "next_steps": ["다음 작업"], }) callouts = [b for b in _flatten_blocks(blocks) if b["type"] == "callout"] + texts = [_block_text(b) for b in _flatten_blocks(blocks)] - assert len(callouts) == 2 + assert len(callouts) == 1 assert _block_text(callouts[0]) == "핵심 요약." - assert _block_text(callouts[1]) == "리스크 1\n리스크 2" + assert "리스크 1" in texts + assert "리스크 2" in texts - def test_code_changes_alias_limits_to_three_bullets(self): + def test_code_changes_alias_renders_as_developer_evidence(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 + assert "Developer Evidence" in texts + assert "Key Changes: change-7" in texts def test_files_section_marks_created_with_plus(self): blocks = build_notion_blocks({ diff --git a/tests/test_notion_push.py b/tests/test_notion_push.py index cf2b80d..32ca049 100644 --- a/tests/test_notion_push.py +++ b/tests/test_notion_push.py @@ -139,6 +139,27 @@ def test_no_commits_zeros(self): assert props["Commits"]["number"] == 0 assert props["Lines"]["number"] == 0 + def test_v2_appendix_files_counted(self): + task = { + "title": "v2 task", + "project": "diary", + "appendix": { + "files_modified": ["src/a.py", "src/b.py"], + "files_created": ["tests/test_a.py"], + }, + } + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert props["Files"]["number"] == 3 + + def test_v2_next_actions_feed_next_action_property(self): + task = { + "title": "v2 task", + "project": "diary", + "next_actions": ["Run dry-run preview", "Push to Notion"], + } + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert props["Next Action"]["rich_text"][0]["text"]["content"] == "Run dry-run preview" + def test_missing_purpose_defaults_to_general(self): task = self._base_task() props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) @@ -482,6 +503,7 @@ def _make_args(input_path, force=False): args = MagicMock() args.input = input_path args.force = force + args.dry_run = False return args @@ -557,6 +579,98 @@ def test_successful_push_cleans_up(self, tmp_path): mock_exp.create_row.assert_called_once() assert not input_path.exists() + def test_v2_appendix_commit_hashes_drive_git_info_and_properties(self, tmp_path): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "tasks": [{ + "title": "v2 task", + "project": "diary", + "appendix": { + "files_modified": ["src/a.py"], + "files_created": ["tests/test_a.py"], + "commit_hashes": ["abc1234"], + }, + }], + }) + 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_branch_for_commit", + return_value="feat/v2") as mock_branch, \ + patch("claude_diary.cli.notion_push.get_commit_info", + return_value={"hash": "abc1234", "short_hash": "abc1234", "message": "v2"}), \ + patch("claude_diary.cli.notion_push.get_diff_stat_for_commits", + return_value={"added": 10, "deleted": 2, "files": 2}), \ + pytest.raises(SystemExit) as exc: + cmd_notion_push(_make_args(str(input_path))) + + assert exc.value.code == 0 + mock_branch.assert_called_once_with(os.getcwd(), "abc1234") + props = mock_exp.create_row.call_args.args[1] + assert props["Branch"]["select"]["name"] == "feat/v2" + assert props["Files"]["number"] == 2 + assert props["Commits"]["number"] == 1 + assert props["Lines"]["number"] == 12 + assert not input_path.exists() + + def test_push_dry_run_renders_preview_without_credentials_or_cleanup(self, tmp_path, capsys): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "tasks": [{ + "title": "dry run v2", + "project": "diary", + "purpose": "Test", + "summary": { + "intro": "Compact report preview", + "outcomes": ["Core renderer ready"], + "verification": ["Tests passed"], + "remaining": ["Install skill"], + }, + "work": { + "context": "v2 rollout", + "scope": "push preview", + "approach": "render blocks locally", + "state": "Testing", + }, + "appendix": { + "files_modified": ["src/claude_diary/cli/notion_push.py"], + "commands_run": ["python -m pytest -q"], + }, + }], + }) + args = _make_args(str(input_path)) + args.dry_run = True + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value={}), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter") as mock_exporter, \ + patch("claude_diary.cli.notion_push.get_head_branch", return_value="feat/v2"): + cmd_notion_push(args) + + out = capsys.readouterr().out + assert "[claude-diary diary-notion push --dry-run]" in out + assert "dry run v2" in out + assert "[Callout] Compact report preview" in out + assert "## 결과" in out + assert "> 명령 / 파일 / 커밋 근거" in out + mock_exporter.assert_not_called() + assert input_path.exists() + def test_missing_project_uses_command_cwd(self, tmp_path, monkeypatch): input_path = tmp_path / "in.json" _write_json(str(input_path), { diff --git a/tests/test_setup.py b/tests/test_setup.py index 907b029..f8e1e5c 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -91,7 +91,7 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): 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 "Write `title`, `summary`" in codex_content assert "in Korean" in codex_content assert "Notion task database record" in codex_content assert "Current Implementation Contract" in codex_content @@ -102,6 +102,10 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): assert "verification-only work" in codex_content assert "prompt-result document" in codex_content assert "prompt_outputs" in codex_content + assert "schema v2 normalized fields" in codex_content + assert "Notion is the report surface" in codex_content + assert "appendix.artifacts" in codex_content + assert "push --input .diary-notion-<8-random>.json --dry-run" in codex_content # Claude /diary-notion slash command carries the implementation contract assert "현재 구현 계약" in DIARY_NOTION_SLASH_COMMAND assert "schema v7" in DIARY_NOTION_SLASH_COMMAND @@ -109,6 +113,10 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): assert "carryover=true" in DIARY_NOTION_SLASH_COMMAND assert "Testing / Verification Sessions" in DIARY_NOTION_SLASH_COMMAND assert "prompt-output artifact fields" in DIARY_NOTION_SLASH_COMMAND + assert "Core Report Schema" in DIARY_NOTION_SLASH_COMMAND + assert "schema v2 normalized fields" in DIARY_NOTION_SLASH_COMMAND + assert "appendix.artifacts" in DIARY_NOTION_SLASH_COMMAND + assert "push --input .diary-notion-<8자리>.json --dry-run" in DIARY_NOTION_SLASH_COMMAND class TestInstallSlashCommandSingle: From e61d10ab9f5c2c822e424357b54d97791db3204c Mon Sep 17 00:00:00 2001 From: cys Date: Fri, 5 Jun 2026 17:19:22 +0900 Subject: [PATCH 4/6] feat(notion): complete v2 report platform --- .gitignore | 3 + skills/diary-notion/SKILL.md | 5 +- src/claude_diary/cli/__init__.py | 6 + src/claude_diary/cli/notion_push.py | 299 +++++++++++++++++- src/claude_diary/cli/setup.py | 12 +- .../exporters/notion_hierarchical.py | 3 +- src/claude_diary/exporters/notion_views.py | 2 +- tests/test_cli.py | 19 ++ tests/test_codex_plugin.py | 4 +- tests/test_notion_hierarchical.py | 6 +- tests/test_notion_push.py | 83 +++++ tests/test_setup.py | 6 +- 12 files changed, 422 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 4eb513a..beff7fb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ claude-code-working-diary.tar.gz # /diary-notion 임시 JSON (Claude가 cwd에 작성 → CLI가 자동 삭제 / 부분 실패 시 보존) .diary-notion-*.json + +# Local v2 diary-notion run artifacts +.codefleet/runs/ diff --git a/skills/diary-notion/SKILL.md b/skills/diary-notion/SKILL.md index 8d48a92..c12f96e 100644 --- a/skills/diary-notion/SKILL.md +++ b/skills/diary-notion/SKILL.md @@ -10,13 +10,14 @@ Split the current Codex session into task-sized entries and push them to Notion. ## Current Implementation Contract - `$diary-notion` is a row push workflow only. Do not run schema/view ensure unless the user explicitly asks for it. -- `working-diary diary-notion ensure` is the separate maintenance command that guarantees schema v7, native sub-items, 5 core views, and 5 operating views. +- `working-diary diary-notion ensure` is the separate maintenance command that guarantees schema v8, native sub-items, 5 core views, and 5 operating views. - Use Notion native sub-items for containment by setting `parent_index`. If the native relation is not enabled in Notion, still push the rows and report that sub-item activation is needed. - Treat legacy `Parent Task` / `Sub-items` as compatibility data only. Do not target them directly in JSON. - Use `Depends On` only for prerequisite links between large top-level main tasks. Never use dependencies for child tasks. - Never write `"unknown"` as `project`; omit it or leave it blank so the CLI falls back to the command cwd folder name. - Page bodies render as compact work reports: summary, results, work table, decisions, issues/risks, next actions/support, and appendix toggles. - Notion is the report surface; raw logs, long diffs, and bulky evidence belong in local artifact files and should be referenced by path/hash instead of pasted. +- `diary-notion push` writes local run artifacts under `.codefleet/runs` by default: `input.json`, `git-diff.patch`, `preview.md`, and `manifest.json`. Use `--preview-file` for an extra Markdown preview path or `--no-artifacts` to disable local artifact writes. - For testing, QA, review, validation, or verification sessions, create a row even without code changes; keep `verification` short and place the meaningful prompt-result document in `prompt_outputs` or `verification_artifacts` so it renders inside a toggle. ## Workflow @@ -153,7 +154,7 @@ Split the current Codex session into task-sized entries and push them to Notion. } ``` -5. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json --dry-run` to preview the compact report body and appendix toggles without writing to Notion. +5. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json --dry-run` to validate v2 input, write local artifacts, and preview the compact report body and appendix toggles without writing to Notion. 6. If the preview is structurally wrong, fix the JSON before pushing. 7. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json`. 8. If `working-diary` is not available, run `claude-diary diary-notion push --input .diary-notion-<8-random>.json`. diff --git a/src/claude_diary/cli/__init__.py b/src/claude_diary/cli/__init__.py index 2cbc289..b7c3988 100644 --- a/src/claude_diary/cli/__init__.py +++ b/src/claude_diary/cli/__init__.py @@ -47,6 +47,12 @@ def _add_diary_notion_parser(sub, name): p_notion.add_argument("--year", type=int, help="Target year (ensure/ops only)") p_notion.add_argument("--dry-run", action="store_true", help="Preview push body without writing, or print ensure plan") + p_notion.add_argument("--preview-file", + help="Write push dry-run/preview Markdown to this file") + p_notion.add_argument("--artifact-dir", default=".codefleet/runs", + help="Directory for local run artifacts (push only)") + p_notion.add_argument("--no-artifacts", action="store_true", + help="Do not write local run artifacts (push only)") p_notion.add_argument("--stale-days", type=int, default=7, help="Mark active rows stale after N days (ops only)") p_notion.add_argument("--json", dest="json_output", action="store_true", diff --git a/src/claude_diary/cli/notion_push.py b/src/claude_diary/cli/notion_push.py index 3dfeb54..38217be 100644 --- a/src/claude_diary/cli/notion_push.py +++ b/src/claude_diary/cli/notion_push.py @@ -13,7 +13,11 @@ import json import os +import hashlib +import shutil +import subprocess import sys +from copy import deepcopy from datetime import datetime, timezone, timedelta from claude_diary.config import load_config @@ -50,11 +54,18 @@ def cmd_notion_push(args): session_id = data.get("session_id") or _fallback_session_id() tasks = data.get("tasks") or [] + validation_errors = _validate_push_data(data) + if validation_errors: + _print_validation_errors(validation_errors, input_path) + sys.exit(1) if not tasks: print("[claude-diary diary-notion push] No tasks to push.") if not dry_run: _cleanup(input_path) return + tasks = deepcopy(tasks) + report_schema_version = _report_schema_version(data, tasks) + _stamp_report_schema_version(tasks, report_schema_version) cwd = os.getcwd() # Hierarchical Notion diary pages are developer work records for this @@ -68,8 +79,24 @@ def cmd_notion_push(args): year = today.year date_str = today.strftime("%Y-%m-%d") + artifact_dir = _arg_str(args, "artifact_dir") + if getattr(args, "no_artifacts", False) is True: + artifact_dir = "" + preview_file = _arg_str(args, "preview_file") if dry_run: - _print_dry_run_preview(tasks, session_id, date_str, cwd, lang) + run_artifacts = _prepare_run_artifacts( + input_path, data, tasks, session_id, date_str, cwd, artifact_dir + ) + if run_artifacts: + _attach_run_artifacts(tasks, run_artifacts) + preview = _build_dry_run_preview(tasks, session_id, date_str, cwd, lang) + if preview_file: + _write_text_file(preview_file, preview) + print("[claude-diary diary-notion push --dry-run] Preview file: %s" % preview_file) + if run_artifacts: + _write_artifact_preview(run_artifacts, preview) + _finalize_artifact_manifest(run_artifacts, tasks) + print(preview) return token, root_page_id = _resolve_credentials(config) @@ -77,6 +104,12 @@ def cmd_notion_push(args): _print_setup_hint() sys.exit(1) + run_artifacts = _prepare_run_artifacts( + input_path, data, tasks, session_id, date_str, cwd, artifact_dir + ) + if run_artifacts: + _attach_run_artifacts(tasks, run_artifacts) + exporter = NotionHierarchicalExporter({ "api_token": token, "root_page_id": root_page_id, @@ -130,6 +163,11 @@ def cmd_notion_push(args): exporter.save_cache() _print_report(results, input_path) + if run_artifacts: + preview = _build_dry_run_preview(tasks, session_id, date_str, cwd, lang) + _write_artifact_preview(run_artifacts, preview) + _finalize_artifact_manifest(run_artifacts, tasks, results) + print("[claude-diary diary-notion push] Artifacts: %s" % run_artifacts["run_dir"]) if not results["failed"]: _cleanup(input_path) @@ -400,6 +438,9 @@ def _build_properties(task, date_str, branch, git_info, session_id, task_index, "Session ID": {"rich_text": [{"text": {"content": session_id[:2000]}}]}, "Task Index": {"number": task_index}, } + report_schema = _clean_schema_version(task.get("_report_schema_version")) + if report_schema: + props["Schema Version"] = {"select": {"name": report_schema}} if branch: props["Branch"] = {"select": {"name": _safe_select(branch)}} @@ -542,6 +583,99 @@ def _clean_rich_text(value): return raw[:2000] if raw else "" +def _clean_schema_version(value): + raw = str(value or "").strip() + if not raw: + return "" + if raw.lower().startswith("v"): + return raw[:100] + return ("v%s" % raw)[:100] + + +def _report_schema_version(data, tasks): + raw = data.get("schema_version") + if raw is not None: + return _clean_schema_version(raw) + for task in tasks: + if isinstance(task, dict) and ( + isinstance(task.get("summary"), dict) + or isinstance(task.get("work"), dict) + or isinstance(task.get("appendix"), dict) + ): + return "v2" + return "legacy" + + +def _stamp_report_schema_version(tasks, version): + for task in tasks: + if isinstance(task, dict): + task["_report_schema_version"] = version + + +def _validate_push_data(data): + errors = [] + if not isinstance(data, dict): + return ["input root must be a JSON object"] + tasks = data.get("tasks") + if tasks is None: + return ["tasks is required"] + if not isinstance(tasks, list): + return ["tasks must be an array"] + + schema_version = data.get("schema_version") + if schema_version is not None and _clean_schema_version(schema_version) != "v2": + errors.append("schema_version must be 2 for normalized report input") + + strict_v2 = _clean_schema_version(schema_version) == "v2" + for idx, task in enumerate(tasks): + prefix = "tasks[%d]" % idx + if not isinstance(task, dict): + errors.append("%s must be an object" % prefix) + continue + if not str(task.get("title") or "").strip(): + errors.append("%s.title is required" % prefix) + if strict_v2: + for key in ("summary", "work", "appendix"): + if not isinstance(task.get(key), dict): + errors.append("%s.%s must be an object for schema_version 2" % (prefix, key)) + status = task.get("status") + if status and str(status).strip() not in VALID_STATUSES: + errors.append("%s.status must be one of: %s" % (prefix, ", ".join(sorted(VALID_STATUSES)))) + priority = task.get("priority") + if priority and not _normalize_priority(priority): + errors.append("%s.priority must be one of: %s" % (prefix, ", ".join(sorted(VALID_PRIORITIES)))) + review = task.get("review_status") + if review and not _normalize_review_status(review): + errors.append("%s.review_status must be one of: %s" % (prefix, ", ".join(sorted(VALID_REVIEW_STATUSES)))) + parent_idx = _get_parent_index(task) + if parent_idx is not None and parent_idx >= len(tasks): + errors.append("%s.parent_index points outside tasks" % prefix) + deps = task.get("depends_on_indices") or [] + if not isinstance(deps, list): + errors.append("%s.depends_on_indices must be an array" % prefix) + else: + for dep in deps: + if not isinstance(dep, int) or dep < 0 or dep >= len(tasks): + errors.append("%s.depends_on_indices contains invalid index %r" % (prefix, dep)) + appendix = task.get("appendix") if isinstance(task.get("appendix"), dict) else {} + artifacts = appendix.get("artifacts") or task.get("artifacts") or [] + if isinstance(artifacts, dict): + artifacts = [artifacts] + if artifacts and not isinstance(artifacts, list): + errors.append("%s.appendix.artifacts must be an array" % prefix) + elif isinstance(artifacts, list): + for art_idx, artifact in enumerate(artifacts): + if isinstance(artifact, dict) and not str(artifact.get("path") or "").strip(): + errors.append("%s.appendix.artifacts[%d].path is required" % (prefix, art_idx)) + return errors + + +def _print_validation_errors(errors, input_path): + print("[claude-diary diary-notion push] Invalid input: %s" % input_path, file=sys.stderr) + for error in errors[:20]: + print(" - %s" % error, file=sys.stderr) + + def _task_appendix(task): value = task.get("appendix") return value if isinstance(value, dict) else {} @@ -596,27 +730,32 @@ def _task_next_action(task): return actions[0] if actions else "" -def _print_dry_run_preview(tasks, session_id, date_str, cwd, lang): - print("[claude-diary diary-notion push --dry-run]") - print("Session ID: %s" % session_id) - print("Tasks: %d" % len(tasks)) +def _build_dry_run_preview(tasks, session_id, date_str, cwd, lang): + lines = [ + "[claude-diary diary-notion push --dry-run]", + "Session ID: %s" % session_id, + "Tasks: %d" % len(tasks), + ] for idx, task in enumerate(tasks): title = task.get("title") or "(untitled)" git_info = _gather_git_info(cwd, _task_commit_hashes(task)) branch = git_info.get("branch") or "" props = _build_properties(task, date_str, branch, git_info, session_id, idx, cwd) blocks = build_notion_blocks(task, git_info, lang) - print("") - print("[%d] %s" % (idx, title)) - print(" Project: %s" % props["Project"]["select"]["name"]) - print(" Purpose: %s" % props["Purpose"]["select"]["name"]) - print(" Files: %d" % props["Files"]["number"]) - print(" Commits: %d" % props["Commits"]["number"]) - print(" Lines: %d" % props["Lines"]["number"]) + lines.append("") + lines.append("[%d] %s" % (idx, title)) + lines.append(" Project: %s" % props["Project"]["select"]["name"]) + lines.append(" Purpose: %s" % props["Purpose"]["select"]["name"]) + if "Schema Version" in props: + lines.append(" Schema Version: %s" % props["Schema Version"]["select"]["name"]) + lines.append(" Files: %d" % props["Files"]["number"]) + lines.append(" Commits: %d" % props["Commits"]["number"]) + lines.append(" Lines: %d" % props["Lines"]["number"]) if branch: - print(" Branch: %s" % branch) + lines.append(" Branch: %s" % branch) for line in _blocks_to_preview_lines(blocks): - print(" %s" % line) + lines.append(" %s" % line) + return "\n".join(lines) def _blocks_to_preview_lines(blocks, max_lines=80): @@ -671,6 +810,138 @@ def _rich_text_plain(rich_text): return "".join(parts) +def _arg_str(args, name): + value = getattr(args, name, None) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _prepare_run_artifacts(input_path, data, tasks, session_id, date_str, cwd, artifact_dir): + if not artifact_dir: + return None + run_id = _run_id(session_id, date_str) + run_dir = os.path.abspath(os.path.join(cwd, artifact_dir, run_id)) + os.makedirs(run_dir, exist_ok=True) + artifacts = { + "run_id": run_id, + "run_dir": run_dir, + "cwd": cwd, + "refs": [], + } + if input_path and os.path.exists(input_path): + input_copy = os.path.join(run_dir, "input.json") + shutil.copyfile(input_path, input_copy) + artifacts["refs"].append(_artifact_ref(cwd, input_copy, "input", "original diary-notion JSON input")) + diff_path = os.path.join(run_dir, "git-diff.patch") + diff_text = _git_diff(cwd) + _write_text_file(diff_path, diff_text) + artifacts["refs"].append(_artifact_ref(cwd, diff_path, "diff", "git diff at diary-notion push time")) + return artifacts + + +def _write_artifact_preview(run_artifacts, preview): + path = os.path.join(run_artifacts["run_dir"], "preview.md") + _write_text_file(path, preview) + run_artifacts["refs"] = [ + ref for ref in run_artifacts["refs"] if ref.get("kind") != "preview" + ] + run_artifacts["refs"].append(_artifact_ref( + run_artifacts["cwd"], path, "preview", "rendered Notion body preview" + )) + + +def _finalize_artifact_manifest(run_artifacts, tasks, results=None): + manifest_path = os.path.join(run_artifacts["run_dir"], "manifest.json") + manifest = { + "run_id": run_artifacts["run_id"], + "tasks": [task.get("title") or "(untitled)" for task in tasks], + "artifacts": run_artifacts["refs"], + } + if results is not None: + manifest["results"] = { + "pushed": len(results.get("pushed") or []), + "skipped": len(results.get("skipped") or []), + "failed": len(results.get("failed") or []), + } + _write_text_file(manifest_path, json.dumps(manifest, ensure_ascii=False, indent=2)) + run_artifacts["refs"] = [ + ref for ref in run_artifacts["refs"] if ref.get("kind") != "manifest" + ] + run_artifacts["refs"].append(_artifact_ref( + run_artifacts["cwd"], manifest_path, "manifest", "local run artifact manifest" + )) + + +def _attach_run_artifacts(tasks, run_artifacts): + refs = run_artifacts.get("refs") or [] + for task in tasks: + appendix = task.setdefault("appendix", {}) + existing = appendix.get("artifacts") or [] + if isinstance(existing, dict): + existing = [existing] + elif not isinstance(existing, list): + existing = [] + appendix["artifacts"] = existing + refs + + +def _artifact_ref(cwd, path, kind, summary): + return { + "kind": kind, + "path": _relpath(cwd, path), + "summary": summary, + "sha256": _sha256_file(path), + } + + +def _run_id(session_id, date_str): + stamp = datetime.now().strftime("%H%M%S") + safe_session = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in str(session_id or "run")) + return "%s-%s-%s" % (date_str.replace("-", ""), stamp, safe_session[:24]) + + +def _git_diff(cwd): + try: + result = subprocess.run( + ["git", "-c", "safe.directory=%s" % cwd.replace("\\", "/"), "diff", "--binary"], + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=20, + ) + except Exception as e: + return "git diff unavailable: %s\n" % e + if result.returncode != 0: + return "git diff failed: %s\n" % (result.stderr or result.stdout) + return result.stdout or "No working tree diff at capture time.\n" + + +def _write_text_file(path, text): + parent = os.path.dirname(os.path.abspath(path)) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(text) + + +def _sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def _relpath(cwd, path): + try: + return os.path.relpath(path, cwd).replace("\\", "/") + except ValueError: + return path.replace("\\", "/") + + def _print_report(results, input_path): pushed = len(results["pushed"]) skipped = len(results["skipped"]) diff --git a/src/claude_diary/cli/setup.py b/src/claude_diary/cli/setup.py index ade98e9..284c84d 100644 --- a/src/claude_diary/cli/setup.py +++ b/src/claude_diary/cli/setup.py @@ -53,13 +53,18 @@ ## 현재 구현 계약 - `/diary-notion`은 작업 row push 전용. 사용자가 명시적으로 요청하지 않는 한 schema/view ensure를 실행하지 말 것 -- `working-diary diary-notion ensure`는 schema v7, native 하위항목, core view 5개, operating view 5개를 보장하는 별도 정비 명령 +- `working-diary diary-notion ensure`는 schema v8, native 하위항목, core view 5개, operating view 5개를 보장하는 별도 정비 명령 - 포함 관계는 `parent_index`로 Notion native 하위항목에 기록. native 관계가 아직 활성화돼 있지 않으면 row는 그대로 push하고 하위항목 활성화가 필요함을 보고할 것 - legacy `Parent Task` / `Sub-items`는 호환용 데이터로만 취급하고 JSON에서 직접 지정하지 말 것 - `Depends On`은 큰 최상위 메인 작업끼리의 선행 연결에만 사용하고, 하위 작업에는 절대 종속성을 쓰지 말 것 - `project`에 `"unknown"`을 쓰지 말 것 — 비우거나 생략하면 CLI가 명령 실행 cwd 폴더명으로 보정 - 페이지 본문은 compact executive body로 렌더링됨: 상단 요약, 결과 체크리스트, 작업 한눈에 표, 영향, 검증, 리스크/다음 액션, 부록 +## Local Artifact Store + +- `diary-notion push` stores local run artifacts under `.codefleet/runs` by default: `input.json`, `git-diff.patch`, `preview.md`, and `manifest.json`. +- Use `--preview-file` for an extra Markdown preview path or `--no-artifacts` to disable local artifact writes. + ## 단계 1. **컨텍스트 수집** @@ -260,13 +265,14 @@ ## Current Implementation Contract - `$diary-notion` is a row push workflow only. Do not run schema/view ensure unless the user explicitly asks for it. -- `working-diary diary-notion ensure` is the separate maintenance command that guarantees schema v7, native sub-items, 5 core views, and 5 operating views. +- `working-diary diary-notion ensure` is the separate maintenance command that guarantees schema v8, native sub-items, 5 core views, and 5 operating views. - Use Notion native sub-items for containment by setting `parent_index`. If the native relation is not enabled in Notion, still push the rows and report that sub-item activation is needed. - Treat legacy `Parent Task` / `Sub-items` as compatibility data only. Do not target them directly in JSON. - Use `Depends On` only for prerequisite links between large top-level main tasks. Never use dependencies for child tasks. - Never write `"unknown"` as `project`; omit it or leave it blank so the CLI falls back to the command cwd folder name. - Page bodies render as compact work reports: summary, results, work table, decisions, issues/risks, next actions/support, and appendix toggles. - Notion is the report surface; raw logs, long diffs, and bulky evidence belong in local artifact files and should be referenced by path/hash instead of pasted. +- `diary-notion push` writes local run artifacts under `.codefleet/runs` by default: `input.json`, `git-diff.patch`, `preview.md`, and `manifest.json`. Use `--preview-file` for an extra Markdown preview path or `--no-artifacts` to disable local artifact writes. - For testing, QA, review, validation, or verification sessions, create a row even without code changes; keep `verification` short and place the meaningful prompt-result document in `prompt_outputs` or `verification_artifacts` so it renders inside a toggle. ## Workflow @@ -403,7 +409,7 @@ } ``` -5. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json --dry-run` to preview the compact report body and appendix toggles without writing to Notion. +5. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json --dry-run` to validate v2 input, write local artifacts, and preview the compact report body and appendix toggles without writing to Notion. 6. If the preview is structurally wrong, fix the JSON before pushing. 7. Run `working-diary diary-notion push --input .diary-notion-<8-random>.json`. 8. If `working-diary` is not available, run `claude-diary diary-notion push --input .diary-notion-<8-random>.json`. diff --git a/src/claude_diary/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py index 1c778b8..0c7dc2c 100644 --- a/src/claude_diary/exporters/notion_hierarchical.py +++ b/src/claude_diary/exporters/notion_hierarchical.py @@ -32,7 +32,7 @@ NOTION_API_BASE = "https://api.notion.com/v1" MAX_RETRIES = 3 RICH_TEXT_LIMIT = 2000 -SCHEMA_VERSION = "v7" +SCHEMA_VERSION = "v8" DATABASE_TITLE = "Entries" # Relation property names this tool creates itself. Notion's *native* sub-item @@ -554,6 +554,7 @@ def _current_schema_extensions(db_id): "Carryover": {"checkbox": {}}, "Review Status": {"select": {}}, "Last Reviewed": {"date": {}}, + "Schema Version": {"select": {}}, } diff --git a/src/claude_diary/exporters/notion_views.py b/src/claude_diary/exporters/notion_views.py index 5d77606..56fed76 100644 --- a/src/claude_diary/exporters/notion_views.py +++ b/src/claude_diary/exporters/notion_views.py @@ -265,7 +265,7 @@ def ensure(self, database_id, today, dry_run=False): if dry_run: result.updates_planned.append("작업 계층") result.warnings.append( - "schema v7 would convert Parent Task to a dual-property " + "schema v8 would convert Parent Task to a dual-property " "Sub-items relation" ) return result diff --git a/tests/test_cli.py b/tests/test_cli.py index 459483f..03600a6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -519,6 +519,25 @@ def test_main_keeps_notion_ensure_alias(self, mock_cmd, capsys): assert args.action == "ensure" assert args.dry_run is True + @patch("claude_diary.cli.cmd_notion_push") + def test_main_dispatches_diary_notion_push_platform_options(self, mock_cmd, capsys): + with patch("sys.argv", [ + "claude-diary", "diary-notion", "push", + "--input", "in.json", + "--dry-run", + "--preview-file", "preview.md", + "--artifact-dir", ".codefleet/runs", + ]): + main() + mock_cmd.assert_called_once() + args = mock_cmd.call_args.args[0] + assert args.action == "push" + assert args.input == "in.json" + assert args.dry_run is True + assert args.preview_file == "preview.md" + assert args.artifact_dir == ".codefleet/runs" + assert args.no_artifacts is False + # ── cmd_trace tests ── diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index ea6052a..51f919f 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -137,7 +137,9 @@ def test_codex_skills_exist_and_cover_diary_workflows(): assert "in Korean" in notion_text assert "Notion task database record" in notion_text assert "Current Implementation Contract" in notion_text - assert "schema v7" in notion_text + assert "schema v8" in notion_text + assert ".codefleet/runs" in notion_text + assert "--preview-file" in notion_text assert "native sub-items" in notion_text assert "Never write `\"unknown\"` as `project`" in notion_text assert "Use `Depends On` only for prerequisite links" in notion_text diff --git a/tests/test_notion_hierarchical.py b/tests/test_notion_hierarchical.py index 040f7d2..3049f5f 100644 --- a/tests/test_notion_hierarchical.py +++ b/tests/test_notion_hierarchical.py @@ -104,6 +104,7 @@ def _make_exporter(): "Carryover", "Review Status", "Last Reviewed", + "Schema Version", ] @@ -128,6 +129,7 @@ def _assert_current_extension_schema(patch_body, db_id): assert props["Carryover"] == {"checkbox": {}} assert props["Review Status"] == {"select": {}} assert props["Last Reviewed"] == {"date": {}} + assert props["Schema Version"] == {"select": {}} class TestValidateConfig: @@ -445,7 +447,7 @@ def test_force_schema_patches_even_when_already_flagged(self, tmp_path): _assert_current_extension_schema(patch_body, "db_known") def test_v3_database_gets_current_schema_upgrade(self, tmp_path): - """A DB already marked v3 still gets the current v7 schema patch.""" + """A DB already marked v3 still gets the current schema patch.""" exp = _make_exporter() with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): exp.load_cache() @@ -466,7 +468,7 @@ def test_v3_database_gets_current_schema_upgrade(self, tmp_path): assert exp._cache["schema_v"]["db_v3"] == SCHEMA_VERSION def test_v2_database_gets_current_schema_upgrade(self, tmp_path): - """A DB already marked v2 still gets the current v7 schema patch.""" + """A DB already marked v2 still gets the current schema patch.""" exp = _make_exporter() with patch("claude_diary.lib.notion_cache.get_config_dir", return_value=str(tmp_path)): exp.load_cache() diff --git a/tests/test_notion_push.py b/tests/test_notion_push.py index 32ca049..a3f194d 100644 --- a/tests/test_notion_push.py +++ b/tests/test_notion_push.py @@ -160,6 +160,11 @@ def test_v2_next_actions_feed_next_action_property(self): props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) assert props["Next Action"]["rich_text"][0]["text"]["content"] == "Run dry-run preview" + def test_report_schema_version_property_included_when_stamped(self): + task = dict(self._base_task(), _report_schema_version="v2") + props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) + assert props["Schema Version"]["select"]["name"] == "v2" + def test_missing_purpose_defaults_to_general(self): task = self._base_task() props = _build_properties(task, "2026-05-26", "main", {}, "sess1", 0) @@ -261,6 +266,10 @@ def _make_args(self, input_path, force=False): args = MagicMock() args.input = input_path args.force = force + args.dry_run = False + args.preview_file = "" + args.artifact_dir = "" + args.no_artifacts = False return args def _write_json(self, path, data): @@ -504,6 +513,9 @@ def _make_args(input_path, force=False): args.input = input_path args.force = force args.dry_run = False + args.preview_file = "" + args.artifact_dir = "" + args.no_artifacts = False return args @@ -671,6 +683,77 @@ def test_push_dry_run_renders_preview_without_credentials_or_cleanup(self, tmp_p mock_exporter.assert_not_called() assert input_path.exists() + def test_v2_validation_rejects_missing_normalized_sections_before_auth(self, tmp_path, capsys): + input_path = tmp_path / "in.json" + _write_json(str(input_path), { + "session_id": "s1", + "schema_version": 2, + "tasks": [{"title": "bad v2"}], + }) + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value={}), \ + patch("claude_diary.cli.notion_push.NotionHierarchicalExporter") as mock_exporter, \ + pytest.raises(SystemExit) as exc: + cmd_notion_push(_make_args(str(input_path))) + + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "Invalid input" in err + assert "tasks[0].summary must be an object" in err + mock_exporter.assert_not_called() + assert input_path.exists() + + def test_dry_run_writes_preview_and_artifacts(self, tmp_path, capsys): + input_path = tmp_path / "in.json" + preview_path = tmp_path / "preview.md" + artifact_root = tmp_path / "runs" + _write_json(str(input_path), { + "session_id": "s1", + "schema_version": 2, + "tasks": [{ + "title": "dry run artifacts", + "project": "diary", + "summary": { + "intro": "Preview with artifacts", + "outcomes": ["Rendered"], + "verification": ["Checked"], + "remaining": [], + }, + "work": { + "context": "Artifact validation", + "scope": "Preview file and manifest", + "approach": "Local render", + "state": "Testing", + }, + "appendix": { + "files_modified": ["src/claude_diary/cli/notion_push.py"], + "commands_run": ["python -m pytest -q"], + }, + }], + }) + args = _make_args(str(input_path)) + args.dry_run = True + args.preview_file = str(preview_path) + args.artifact_dir = str(artifact_root) + + with patch.dict(os.environ, {}, clear=True), \ + patch("claude_diary.cli.notion_push.load_config", return_value={}), \ + patch("claude_diary.cli.notion_push.get_head_branch", return_value="feat/v2"): + cmd_notion_push(args) + + out = capsys.readouterr().out + assert "Preview file:" in out + assert preview_path.exists() + assert "Schema Version: v2" in preview_path.read_text(encoding="utf-8") + run_dirs = list(artifact_root.iterdir()) + assert len(run_dirs) == 1 + names = {p.name for p in run_dirs[0].iterdir()} + assert {"input.json", "git-diff.patch", "preview.md", "manifest.json"} <= names + manifest = json.loads((run_dirs[0] / "manifest.json").read_text(encoding="utf-8")) + assert manifest["run_id"] + assert any(a["kind"] == "preview" for a in manifest["artifacts"]) + def test_missing_project_uses_command_cwd(self, tmp_path, monkeypatch): input_path = tmp_path / "in.json" _write_json(str(input_path), { diff --git a/tests/test_setup.py b/tests/test_setup.py index f8e1e5c..b4b26ce 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -95,7 +95,7 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): assert "in Korean" in codex_content assert "Notion task database record" in codex_content assert "Current Implementation Contract" in codex_content - assert "schema v7" in codex_content + assert "schema v8" in codex_content assert "native sub-items" in codex_content assert "Never write `\"unknown\"` as `project`" in codex_content assert "testing, QA, review, validation, or verification sessions" in codex_content @@ -106,9 +106,10 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): assert "Notion is the report surface" in codex_content assert "appendix.artifacts" in codex_content assert "push --input .diary-notion-<8-random>.json --dry-run" in codex_content + assert ".codefleet/runs" in codex_content # Claude /diary-notion slash command carries the implementation contract assert "현재 구현 계약" in DIARY_NOTION_SLASH_COMMAND - assert "schema v7" in DIARY_NOTION_SLASH_COMMAND + assert "schema v8" in DIARY_NOTION_SLASH_COMMAND assert "native 하위항목" in DIARY_NOTION_SLASH_COMMAND assert "carryover=true" in DIARY_NOTION_SLASH_COMMAND assert "Testing / Verification Sessions" in DIARY_NOTION_SLASH_COMMAND @@ -117,6 +118,7 @@ def test_slash_and_codex_skill_request_rich_body_fields(self): assert "schema v2 normalized fields" in DIARY_NOTION_SLASH_COMMAND assert "appendix.artifacts" in DIARY_NOTION_SLASH_COMMAND assert "push --input .diary-notion-<8자리>.json --dry-run" in DIARY_NOTION_SLASH_COMMAND + assert ".codefleet/runs" in DIARY_NOTION_SLASH_COMMAND class TestInstallSlashCommandSingle: From f8886340d9909655ad2e9bf8013d57b4f94b89a5 Mon Sep 17 00:00:00 2001 From: cys Date: Fri, 5 Jun 2026 17:48:25 +0900 Subject: [PATCH 5/6] fix(notion): improve v2 artifact report output --- src/claude_diary/cli/notion_push.py | 42 ++++++++++++++++++++++------ src/claude_diary/formatter.py | 43 +++++++++++++++++++++++++---- tests/test_formatter.py | 19 +++++++++++++ tests/test_notion_push.py | 3 ++ 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/claude_diary/cli/notion_push.py b/src/claude_diary/cli/notion_push.py index 38217be..8f0436c 100644 --- a/src/claude_diary/cli/notion_push.py +++ b/src/claude_diary/cli/notion_push.py @@ -88,14 +88,13 @@ def cmd_notion_push(args): input_path, data, tasks, session_id, date_str, cwd, artifact_dir ) if run_artifacts: - _attach_run_artifacts(tasks, run_artifacts) + _complete_run_artifacts_before_render( + run_artifacts, tasks, session_id, date_str, cwd, lang + ) preview = _build_dry_run_preview(tasks, session_id, date_str, cwd, lang) if preview_file: _write_text_file(preview_file, preview) print("[claude-diary diary-notion push --dry-run] Preview file: %s" % preview_file) - if run_artifacts: - _write_artifact_preview(run_artifacts, preview) - _finalize_artifact_manifest(run_artifacts, tasks) print(preview) return @@ -108,7 +107,9 @@ def cmd_notion_push(args): input_path, data, tasks, session_id, date_str, cwd, artifact_dir ) if run_artifacts: - _attach_run_artifacts(tasks, run_artifacts) + _complete_run_artifacts_before_render( + run_artifacts, tasks, session_id, date_str, cwd, lang + ) exporter = NotionHierarchicalExporter({ "api_token": token, @@ -164,8 +165,6 @@ def cmd_notion_push(args): _print_report(results, input_path) if run_artifacts: - preview = _build_dry_run_preview(tasks, session_id, date_str, cwd, lang) - _write_artifact_preview(run_artifacts, preview) _finalize_artifact_manifest(run_artifacts, tasks, results) print("[claude-diary diary-notion push] Artifacts: %s" % run_artifacts["run_dir"]) @@ -773,7 +772,7 @@ def _blocks_to_preview_lines(blocks, max_lines=80): lines.append("- %s" % _block_text(block, "bulleted_list_item")) elif block_type == "toggle": lines.append("> %s" % _block_text(block, "toggle")) - for child in block.get("toggle", {}).get("children", [])[:3]: + for child in block.get("toggle", {}).get("children", [])[:8]: child_type = child.get("type") if child_type: lines.append(" - %s" % _block_text(child, child_type)) @@ -851,6 +850,15 @@ def _write_artifact_preview(run_artifacts, preview): )) +def _complete_run_artifacts_before_render(run_artifacts, tasks, session_id, date_str, cwd, lang): + preview_tasks = deepcopy(tasks) + _set_run_artifacts(preview_tasks, run_artifacts) + preview = _build_dry_run_preview(preview_tasks, session_id, date_str, cwd, lang) + _write_artifact_preview(run_artifacts, preview) + _finalize_artifact_manifest(run_artifacts, tasks) + _set_run_artifacts(tasks, run_artifacts) + + def _finalize_artifact_manifest(run_artifacts, tasks, results=None): manifest_path = os.path.join(run_artifacts["run_dir"], "manifest.json") manifest = { @@ -885,6 +893,24 @@ def _attach_run_artifacts(tasks, run_artifacts): appendix["artifacts"] = existing + refs +def _set_run_artifacts(tasks, run_artifacts): + refs = run_artifacts.get("refs") or [] + ref_keys = {(ref.get("kind"), ref.get("path")) for ref in refs} + for task in tasks: + appendix = task.setdefault("appendix", {}) + existing = appendix.get("artifacts") or [] + if isinstance(existing, dict): + existing = [existing] + elif not isinstance(existing, list): + existing = [] + cleaned = [] + for item in existing: + if isinstance(item, dict) and (item.get("kind"), item.get("path")) in ref_keys: + continue + cleaned.append(item) + appendix["artifacts"] = cleaned + refs + + def _artifact_ref(cwd, path, kind, summary): return { "kind": kind, diff --git a/src/claude_diary/formatter.py b/src/claude_diary/formatter.py index 8a63372..75c5834 100644 --- a/src/claude_diary/formatter.py +++ b/src/claude_diary/formatter.py @@ -173,11 +173,11 @@ def normalize_notion_task(task): task.get("summary_hints"), task.get("outcome"), ) - next_actions = _merge_texts( + next_actions = _filter_noop_texts(_merge_texts( task.get("next_actions"), task.get("next_steps"), task.get("next_action"), - ) + )) risks = _merge_texts( task.get("risks"), task.get("cautions"), @@ -208,7 +208,7 @@ def normalize_notion_task(task): "intro": _first_text(summary.get("intro"), task.get("body_intro")), "outcomes": outcomes, "verification": verification, - "remaining": _merge_texts(summary.get("remaining"), task.get("remaining_work")), + "remaining": _filter_noop_texts(_merge_texts(summary.get("remaining"), task.get("remaining_work"))), }, "work": { "context": _first_text(work.get("context"), task.get("work_context"), task.get("context")), @@ -220,7 +220,7 @@ def normalize_notion_task(task): "decisions": _merge_texts(task.get("decisions")), "risks": risks, "next_actions": next_actions, - "support_needed": _merge_texts(task.get("support_needed")), + "support_needed": _filter_noop_texts(_merge_texts(task.get("support_needed"))), "appendix": { "key_changes": _merge_texts( appendix.get("key_changes"), @@ -369,7 +369,7 @@ def _build_command_file_commit_items(task, git_info, L): for e in errors[:2]: evidence.append("%s: %s" % (L("issues"), _truncate(e, 500))) - for a in _format_artifacts(appendix.get("artifacts"))[:3]: + for a in _format_artifacts(appendix.get("artifacts"))[:5]: evidence.append("%s: %s" % (L("artifacts"), a)) return evidence @@ -488,6 +488,39 @@ def _dedupe_texts(items): return result +def _filter_noop_texts(items): + return [item for item in _as_text_list(items) if not _is_noop_text(item)] + + +def _is_noop_text(value): + text = str(value or "").strip().lower().strip(".") + normalized = " ".join(text.split()) + noop_prefixes = ( + "no follow-up needed", + "no follow up needed", + "no further action", + "no action needed", + "not needed", + ) + if any(normalized.startswith(prefix) for prefix in noop_prefixes): + return True + return normalized in { + "", + "none", + "n/a", + "na", + "no follow-up needed", + "no follow up needed", + "no further action", + "no action needed", + "not needed", + "없음", + "해당 없음", + "후속 조치 없음", + "추가 조치 없음", + } + + def _labeled_texts(label, items, max_items, limit=500): return ["%s: %s" % (label, item) for item in _limited_texts(items, max_items, limit)] diff --git a/tests/test_formatter.py b/tests/test_formatter.py index b717dd6..ce71f6d 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -258,6 +258,25 @@ def test_normalized_schema_renders_report_and_artifact_toggles(self): assert "Artifacts: stdout: .codefleet/runs/20260605-001/stdout.log - test output (sha256: abcdef123456)" in texts assert "Work Highlights: Short report body" in texts + def test_noop_followups_are_not_rendered_as_next_action_todos(self): + blocks = build_notion_blocks({ + "summary": { + "intro": "Done.", + "outcomes": ["Completed"], + "remaining": ["None"], + }, + "work": {"state": "Reviewed"}, + "next_action": "No follow-up needed.", + "next_actions": ["없음"], + "support_needed": ["해당 없음"], + }, lang="en") + texts = [_block_text(b) for b in _flatten_blocks(blocks)] + todos = [_block_text(b) for b in _flatten_blocks(blocks) if b["type"] == "to_do"] + + assert "Next Actions / Support" not in texts + assert "No follow-up needed." not in todos + assert "None" not in todos + def test_testing_tasks_move_full_verification_results_to_prompt_output_toggle(self): blocks = build_notion_blocks({ "status": "Testing", diff --git a/tests/test_notion_push.py b/tests/test_notion_push.py index a3f194d..e31f2cd 100644 --- a/tests/test_notion_push.py +++ b/tests/test_notion_push.py @@ -744,6 +744,8 @@ def test_dry_run_writes_preview_and_artifacts(self, tmp_path, capsys): out = capsys.readouterr().out assert "Preview file:" in out + assert "preview.md" in out + assert "manifest.json" in out assert preview_path.exists() assert "Schema Version: v2" in preview_path.read_text(encoding="utf-8") run_dirs = list(artifact_root.iterdir()) @@ -753,6 +755,7 @@ def test_dry_run_writes_preview_and_artifacts(self, tmp_path, capsys): manifest = json.loads((run_dirs[0] / "manifest.json").read_text(encoding="utf-8")) assert manifest["run_id"] assert any(a["kind"] == "preview" for a in manifest["artifacts"]) + assert any(a["kind"] == "manifest" for a in manifest["artifacts"]) is False def test_missing_project_uses_command_cwd(self, tmp_path, monkeypatch): input_path = tmp_path / "in.json" From c6f67142c5a83afb39ec95dba0a4f1fb1caf240c Mon Sep 17 00:00:00 2001 From: cys Date: Fri, 5 Jun 2026 17:54:17 +0900 Subject: [PATCH 6/6] fix(notion): show complete artifact refs in dry-run --- src/claude_diary/cli/notion_push.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/claude_diary/cli/notion_push.py b/src/claude_diary/cli/notion_push.py index 8f0436c..6f71110 100644 --- a/src/claude_diary/cli/notion_push.py +++ b/src/claude_diary/cli/notion_push.py @@ -772,7 +772,7 @@ def _blocks_to_preview_lines(blocks, max_lines=80): lines.append("- %s" % _block_text(block, "bulleted_list_item")) elif block_type == "toggle": lines.append("> %s" % _block_text(block, "toggle")) - for child in block.get("toggle", {}).get("children", [])[:8]: + for child in block.get("toggle", {}).get("children", [])[:20]: child_type = child.get("type") if child_type: lines.append(" - %s" % _block_text(child, child_type))