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/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/skills/diary-notion/SKILL.md b/skills/diary-notion/SKILL.md index 86ce528..c12f96e 100644 --- a/skills/diary-notion/SKILL.md +++ b/skills/diary-notion/SKILL.md @@ -10,26 +10,33 @@ 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 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. +- `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 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: + - 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`, `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 @@ -37,11 +44,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 - - 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 @@ -55,12 +64,16 @@ 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 + - `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 - `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 @@ -81,24 +94,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": ["..."], "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", @@ -113,20 +148,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 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`. +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 5092a79..b7c3988 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,29 @@ 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)") + 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", + 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/cli/notion_push.py b/src/claude_diary/cli/notion_push.py index 3d33b64..6f71110 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 @@ -41,11 +45,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) @@ -54,10 +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.") - _cleanup(input_path) + 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 @@ -71,6 +79,38 @@ 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: + run_artifacts = _prepare_run_artifacts( + input_path, data, tasks, session_id, date_str, cwd, artifact_dir + ) + if 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) + print(preview) + return + + token, root_page_id = _resolve_credentials(config) + if not token or not root_page_id: + _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: + _complete_run_artifacts_before_render( + run_artifacts, tasks, session_id, date_str, cwd, lang + ) + exporter = NotionHierarchicalExporter({ "api_token": token, "root_page_id": root_page_id, @@ -124,6 +164,9 @@ def cmd_notion_push(args): exporter.save_cache() _print_report(results, input_path) + if run_artifacts: + _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) @@ -296,7 +339,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 +419,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 = { @@ -397,6 +437,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)}} @@ -412,7 +455,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 +582,392 @@ 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 {} + + +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 _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) + 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: + lines.append(" Branch: %s" % branch) + for line in _blocks_to_preview_lines(blocks): + lines.append(" %s" % line) + return "\n".join(lines) + + +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", [])[:20]: + 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 _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 _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 = { + "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 _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, + "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 ba470f2..284c84d 100644 --- a/src/claude_diary/cli/setup.py +++ b/src/claude_diary/cli/setup.py @@ -33,18 +33,38 @@ # /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. + +## 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. ## 현재 구현 계약 - `/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. **컨텍스트 수집** @@ -131,7 +151,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 형식 @@ -244,26 +265,33 @@ ## 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 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. +- `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 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: + - 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`, `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 @@ -271,11 +299,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 - - 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 @@ -289,12 +319,16 @@ - 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 + - `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 - `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 @@ -315,24 +349,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": ["..."], "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", @@ -347,21 +403,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 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`. +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/exporters/notion_hierarchical.py b/src/claude_diary/exporters/notion_hierarchical.py index e8f4039..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 @@ -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 = { @@ -536,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/src/claude_diary/formatter.py b/src/claude_diary/formatter.py index a5aabd5..75c5834 100644 --- a/src/claude_diary/formatter.py +++ b/src/claude_diary/formatter.py @@ -6,6 +6,11 @@ from claude_diary.i18n import get_label +DEFAULT_VERIFICATION_LIMIT = 3 +PROMPT_OUTPUT_LIMIT = 15 +APPENDIX_ITEM_LIMIT = 10 + + def format_entry(entry_data, lang="ko"): """Format entry_data into a markdown diary entry.""" L = lambda key: get_label(key, lang) @@ -132,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) @@ -151,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 = _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"), + 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": _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")), + "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": _filter_noop_texts(_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: @@ -175,68 +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"), 3): - 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 = [] - developer = _build_developer_evidence_items(task, git_info, L) + prompt_outputs = _build_prompt_output_items(task) + 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 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(work.get("highlights"), 3): + evidence.append("%s: %s" % (L("work_highlights"), c)) - for c in _limited_texts(task.get("key_changes"), 3): + 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))) @@ -255,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"))[:5]: + 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 = [] @@ -279,6 +446,81 @@ def _significant_commands(commands): return sig +def _build_prompt_output_items(task): + appendix = task.get("appendix") or {} + items = [] + 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)) + + +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 _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/src/claude_diary/i18n.py b/src/claude_diary/i18n.py index 1795d1b..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": "결정 / 트레이드오프", @@ -39,6 +47,7 @@ "risks_next_steps": "리스크 및 다음 단계", "evidence": "원자료", "appendix": "부록", + "prompt_outputs": "프롬프트 결과물 / 검증 산출물", "developer_evidence": "개발 근거", "raw_evidence": "원문 요청", "issues": "발생한 이슈", @@ -84,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", @@ -94,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", @@ -111,6 +128,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_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 05ff918..51f919f 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 @@ -134,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_formatter.py b/tests/test_formatter.py index 70f7d4d..ce71f6d 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,11 +188,136 @@ 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_work_snapshot_uses_table_not_callouts(self): + 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_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", + "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 todos == ["result-0"] + assert "result-2" in bullets + assert "result-7" in bullets + + def test_default_tasks_keep_one_verification_result_in_summary(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-0" in texts + assert "verify-1" 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_report_uses_table_not_callouts(self): blocks = build_notion_blocks({ "work_context": "Started from UX feedback.", "work_scope": "Changed the Notion body renderer.", @@ -203,13 +327,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"], @@ -220,20 +345,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_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..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() @@ -576,6 +578,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 diff --git a/tests/test_notion_push.py b/tests/test_notion_push.py index cf2b80d..e31f2cd 100644 --- a/tests/test_notion_push.py +++ b/tests/test_notion_push.py @@ -139,6 +139,32 @@ 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_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) @@ -240,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): @@ -482,6 +512,10 @@ def _make_args(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 @@ -557,6 +591,172 @@ 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_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.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()) + 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"]) + 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" _write_json(str(input_path), { diff --git a/tests/test_setup.py b/tests/test_setup.py index c3b45bb..b4b26ce 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", @@ -89,18 +91,34 @@ 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 - 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 + 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 + 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 + 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 + assert ".codefleet/runs" in DIARY_NOTION_SLASH_COMMAND class TestInstallSlashCommandSingle: