diff --git a/readme.md b/readme.md index 1c4074b..bf7eff2 100644 --- a/readme.md +++ b/readme.md @@ -468,10 +468,11 @@ remain plain `pytest`, `streamlit`, and `docker compose`. Run `make digest` weekly, or after a heavy capture/editing session, to surface useful signal without routing it anywhere. The digest is local and report-only: -it links back to captures, run manifests, and exports, but it does not email, -post, create issues, or schedule follow-ups unless a later explicit workflow -adds that routing. By default it filters known smoke/demo captures so real -session signal stays readable; use +it links back to captures, run manifests, and exports, but by default it does +not email, post, create issues, or schedule follow-ups. Approved local routing +requires both `--route-to` and `--approve-routing`; without approval the routing +result stays a visible dry-run. By default it filters known smoke/demo captures +so real session signal stays readable; use `venv/bin/python scripts/resurfacing_digest.py --include-all-captures` to include everything. @@ -505,6 +506,7 @@ include everything. | `WHISPERFORGE_HANDOFF_GITHUB_REPO` | Default GitHub repo for approved handoff issue creation (`owner/name`) | no | | `WHISPERFORGE_HANDOFF_LINEAR_TEAM_ID` | Default Linear team ID for approved handoff issue creation | no | | `WHISPERFORGE_HANDOFF_FOLLOWUP_QUEUE_PATH` | Default local JSONL queue path for approved follow-up routing | no | +| `WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR` | Local directory for approved Notion task/page draft routing | no | | `WHISPERFORGE_E2E_FIXTURE_PATH` | Fixture payload path for browser E2E runs (used by `make browser-e2e-fresh`) | no | | `WHISPERFORGE_DISCOVER_OLLAMA` | Set `0`/`false` to skip sidebar Ollama model discovery | no | | `WF_RAG` | Force RAG on/off (`1`/`true` or `0`/`false`) | no | diff --git a/scripts/resurfacing_digest.py b/scripts/resurfacing_digest.py index 2c259d0..3ce31cd 100644 --- a/scripts/resurfacing_digest.py +++ b/scripts/resurfacing_digest.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import os import sys from pathlib import Path @@ -22,15 +23,67 @@ def main() -> int: action="store_true", help="Include smoke/demo captures instead of default real-signal filtering.", ) + parser.add_argument("--route-to", choices=resurfacing.DIGEST_ROUTE_DESTINATIONS) + parser.add_argument( + "--approve-routing", + action="store_true", + help="Allow the selected digest routing destination to write.", + ) + parser.add_argument( + "--routing-dry-run", + action="store_true", + help="Preview approved routing without writing.", + ) + parser.add_argument( + "--followup-queue-path", + default=os.getenv("WHISPERFORGE_HANDOFF_FOLLOWUP_QUEUE_PATH", ""), + help="JSONL path for approved follow-up queue routing.", + ) + parser.add_argument( + "--notion-draft-dir", + default=os.getenv("WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR", ""), + help="Directory for approved Notion task/page draft files.", + ) args = parser.parse_args() + digest = resurfacing.build_digest( + limit=args.limit, + include_nonprod=args.include_all_captures, + ) path = resurfacing.write_digest( args.output_dir, limit=args.limit, include_nonprod=args.include_all_captures, + digest=digest, ) print(path) + if args.route_to: + result = resurfacing.route_digest( + digest, + destination=args.route_to, + approved=args.approve_routing, + dry_run=args.routing_dry_run, + queue_path=args.followup_queue_path, + notion_draft_dir=args.notion_draft_dir, + ) + print(_format_route_result(result)) return 0 +def _format_route_result(result) -> str: + state = "dry-run" if result.dry_run else "created" if result.success else "failed" + lines = [f"Routing {state}: {result.target}"] + if result.url: + lines.append(f"URL: {result.url}") + if result.error: + lines.append(f"Reason: {result.error}") + if result.details.get("message"): + lines.append(f"Note: {result.details['message']}") + if result.details.get("draft_path"): + lines.append(f"Draft path: {result.details['draft_path']}") + if result.details.get("queue_path"): + lines.append(f"Queue path: {result.details['queue_path']}") + return "\n".join(lines) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/test_handoff_router.py b/tests/test_handoff_router.py index b48a0af..877ad7d 100644 --- a/tests/test_handoff_router.py +++ b/tests/test_handoff_router.py @@ -278,9 +278,14 @@ def test_routing_available_reports_both_off_when_unconfigured(monkeypatch): monkeypatch.delenv("LINEAR_API_KEY", raising=False) monkeypatch.delenv("WHISPERFORGE_HANDOFF_LINEAR_TEAM_ID", raising=False) monkeypatch.delenv("WHISPERFORGE_HANDOFF_FOLLOWUP_QUEUE_PATH", raising=False) + monkeypatch.delenv("WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR", raising=False) assert handoff_router.routing_available() == { - "github": False, "linear": False, "followup_queue": False + "github": False, + "linear": False, + "followup_queue": False, + "notion_page_draft": False, + "notion_task_draft": False, } @@ -290,9 +295,14 @@ def test_routing_available_detects_both_on_when_configured(monkeypatch): monkeypatch.setenv("LINEAR_API_KEY", "key") monkeypatch.setenv("WHISPERFORGE_HANDOFF_LINEAR_TEAM_ID", "team-uuid") monkeypatch.setenv("WHISPERFORGE_HANDOFF_FOLLOWUP_QUEUE_PATH", "/tmp/followups.jsonl") + monkeypatch.setenv("WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR", "/tmp/notion-drafts") assert handoff_router.routing_available() == { - "github": True, "linear": True, "followup_queue": True + "github": True, + "linear": True, + "followup_queue": True, + "notion_page_draft": True, + "notion_task_draft": True, } @@ -369,3 +379,58 @@ def boom(*args, **kwargs): assert result.success is False assert result.dry_run is False assert "disk full" in (result.error or "") + + +# --- Notion drafts -------------------------------------------------------- + + +def test_notion_draft_dry_run_skips_file_write(monkeypatch, tmp_path): + monkeypatch.delenv("WHISPERFORGE_HANDOFF_DRY_RUN", raising=False) + + result = handoff_router.create_notion_draft( + draft_dir=str(tmp_path), + title="Digest draft", + body="Body", + draft_type="page", + dry_run=True, + ) + + assert result.success is True + assert result.dry_run is True + assert list(tmp_path.iterdir()) == [] + + +def test_notion_draft_missing_dir_returns_dry_run_with_error(monkeypatch): + monkeypatch.delenv("WHISPERFORGE_HANDOFF_DRY_RUN", raising=False) + + result = handoff_router.create_notion_draft( + draft_dir="", + title="Digest draft", + body="Body", + draft_type="task", + dry_run=False, + ) + + assert result.success is False + assert result.dry_run is True + assert "WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR" in (result.error or "") + + +def test_notion_draft_success_writes_local_markdown(monkeypatch, tmp_path): + monkeypatch.delenv("WHISPERFORGE_HANDOFF_DRY_RUN", raising=False) + + result = handoff_router.create_notion_draft( + draft_dir=str(tmp_path), + title="Digest follow-up", + body="Digest body", + draft_type="task", + dry_run=False, + ) + + path = tmp_path / "digest-follow-up.task.md" + assert result.success is True + assert result.dry_run is False + assert result.url == path.resolve().as_uri() + assert result.details["draft_path"] == str(path) + assert "draft_type: notion_task" in path.read_text(encoding="utf-8") + assert "Digest body" in path.read_text(encoding="utf-8") diff --git a/tests/test_resurfacing.py b/tests/test_resurfacing.py index 6cb8854..7f27106 100644 --- a/tests/test_resurfacing.py +++ b/tests/test_resurfacing.py @@ -1,10 +1,26 @@ """Tests for report-only resurfacing digests.""" +import json from datetime import datetime, timezone from whisperforge_core import captures, resurfacing, run_artifacts +def _routing_digest(): + sections = {name: [] for name in resurfacing.DIGEST_SECTIONS} + sections["Unresolved follow-ups"].append({ + "title": "Follow up with Maya", + "source": "capture:cap-1", + "detail": "Capture status is `captured`.", + "link": "/tmp/cap-1.json", + }) + return { + "generated_at": "2026-05-18T00:00:00Z", + "mode": "report-only", + "sections": sections, + } + + def test_digest_groups_captures_runs_and_source_links(tmp_path, monkeypatch): monkeypatch.setattr(captures, "CAPTURES_DIR", tmp_path / "captures") monkeypatch.setattr(run_artifacts, "RUNS_DIR", tmp_path / "runs") @@ -50,6 +66,72 @@ def test_render_markdown_is_report_only_and_has_all_sections(): assert f"## {section}" in markdown +def test_digest_route_without_approval_stays_report_only(tmp_path): + queue_path = tmp_path / "followups.jsonl" + + result = resurfacing.route_digest( + _routing_digest(), + destination="followup_queue", + approved=False, + queue_path=str(queue_path), + ) + + assert result.success is True + assert result.dry_run is True + assert result.details["approval_required"] is True + assert not queue_path.exists() + + +def test_digest_route_approved_followup_queue_writes(tmp_path, monkeypatch): + queue_path = tmp_path / "followups.jsonl" + monkeypatch.delenv("WHISPERFORGE_HANDOFF_DRY_RUN", raising=False) + + result = resurfacing.route_digest( + _routing_digest(), + destination="followup_queue", + approved=True, + queue_path=str(queue_path), + ) + + record = json.loads(queue_path.read_text(encoding="utf-8").splitlines()[0]) + assert result.success is True + assert result.dry_run is False + assert record["title"] == "WhisperForge resurfacing digest 2026-05-18" + assert "Mode: report-only" in record["body"] + assert "Follow up with Maya" in record["body"] + + +def test_digest_route_approved_dry_run_skips_notion_draft_write(tmp_path, monkeypatch): + monkeypatch.delenv("WHISPERFORGE_HANDOFF_DRY_RUN", raising=False) + + result = resurfacing.route_digest( + _routing_digest(), + destination="notion_page_draft", + approved=True, + dry_run=True, + notion_draft_dir=str(tmp_path), + ) + + assert result.success is True + assert result.dry_run is True + assert list(tmp_path.iterdir()) == [] + + +def test_digest_route_missing_notion_draft_config_is_visible(monkeypatch): + monkeypatch.delenv("WHISPERFORGE_HANDOFF_DRY_RUN", raising=False) + monkeypatch.delenv("WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR", raising=False) + + result = resurfacing.route_digest( + _routing_digest(), + destination="notion_task_draft", + approved=True, + ) + + assert result.success is False + assert result.dry_run is True + assert "WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR" in (result.error or "") + + def test_digest_filters_demo_and_smoke_captures_by_default(tmp_path, monkeypatch): monkeypatch.setattr(captures, "CAPTURES_DIR", tmp_path / "captures") monkeypatch.setattr(run_artifacts, "RUNS_DIR", tmp_path / "runs") diff --git a/whisperforge_core/handoff_router.py b/whisperforge_core/handoff_router.py index e0d6bbc..874f18f 100644 --- a/whisperforge_core/handoff_router.py +++ b/whisperforge_core/handoff_router.py @@ -22,11 +22,12 @@ import json import os +import re import shutil import subprocess +from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from dataclasses import dataclass, field from typing import Optional import requests @@ -66,7 +67,14 @@ def routing_available() -> dict[str, bool]: os.getenv("WHISPERFORGE_HANDOFF_LINEAR_TEAM_ID") ) followup_ok = bool(os.getenv("WHISPERFORGE_HANDOFF_FOLLOWUP_QUEUE_PATH")) - return {"github": github_ok, "linear": linear_ok, "followup_queue": followup_ok} + notion_draft_ok = bool(os.getenv("WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR")) + return { + "github": github_ok, + "linear": linear_ok, + "followup_queue": followup_ok, + "notion_page_draft": notion_draft_ok, + "notion_task_draft": notion_draft_ok, + } def create_github_issue( @@ -260,6 +268,62 @@ def create_followup_queue_item( ) +def create_notion_draft( + *, + draft_dir: str, + title: str, + body: str, + draft_type: str, + dry_run: bool = False, +) -> HandoffResult: + target = f"notion_{draft_type}_draft" + if _force_dry_run() or dry_run: + return HandoffResult(success=True, target=target, dry_run=True) + if draft_type not in {"page", "task"}: + return HandoffResult( + success=False, + target=target, + error="Notion draft type must be `page` or `task`.", + ) + if not draft_dir: + return HandoffResult( + success=False, + target=target, + dry_run=True, + error=( + "No Notion draft directory configured (set " + "WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR or pass draft_dir)." + ), + ) + + directory = Path(draft_dir).expanduser() + path = directory / f"{_slug(title)}.{draft_type}.md" + content = "\n".join([ + "---", + f"draft_type: notion_{draft_type}", + "status: draft", + "---", + "", + f"# {title}", + "", + body.strip(), + "", + ]) + try: + directory.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + except OSError as exc: + logger.warning("Notion draft write failed: %s", exc) + return HandoffResult(success=False, target=target, error=str(exc)) + + return HandoffResult( + success=True, + target=target, + url=path.resolve().as_uri(), + details={"draft_path": str(path), "draft_type": draft_type}, + ) + + def _extract_github_url(stdout: str) -> Optional[str]: # `gh issue create` prints the URL on its own line (sometimes after a # "Creating issue in ..." banner). Take the last https URL we see. @@ -268,3 +332,8 @@ def _extract_github_url(stdout: str) -> Optional[str]: if line.startswith("https://"): return line return None + + +def _slug(value: str) -> str: + slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "").lower()).strip(".-") + return slug[:80] or "notion-draft" diff --git a/whisperforge_core/resurfacing.py b/whisperforge_core/resurfacing.py index 599483a..f5b3405 100644 --- a/whisperforge_core/resurfacing.py +++ b/whisperforge_core/resurfacing.py @@ -2,12 +2,13 @@ from __future__ import annotations +import os from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any -from . import captures, run_artifacts +from . import captures, handoff_router, run_artifacts from .config import CACHE_DIR DIGEST_SECTIONS = [ @@ -20,6 +21,11 @@ "Topic evolution", ] DEFAULT_DIGEST_DIR = CACHE_DIR / "digests" +DIGEST_ROUTE_DESTINATIONS = [ + "followup_queue", + "notion_page_draft", + "notion_task_draft", +] @dataclass @@ -220,16 +226,80 @@ def write_digest( *, limit: int = 50, include_nonprod: bool = False, + digest: dict[str, Any] | None = None, ) -> Path: out_dir = out_dir or DEFAULT_DIGEST_DIR out_dir.mkdir(parents=True, exist_ok=True) - digest = build_digest(limit=limit, include_nonprod=include_nonprod) + digest = digest or build_digest(limit=limit, include_nonprod=include_nonprod) stamp = digest["generated_at"][:10] path = out_dir / f"{stamp}-resurfacing-digest.md" path.write_text(render_markdown(digest), encoding="utf-8") return path +def route_digest( + digest: dict[str, Any], + *, + destination: str, + approved: bool = False, + dry_run: bool = False, + queue_path: str | None = None, + notion_draft_dir: str | None = None, +) -> handoff_router.HandoffResult: + if destination not in DIGEST_ROUTE_DESTINATIONS: + return handoff_router.HandoffResult( + success=False, + target=destination, + error=f"Unsupported digest routing destination: {destination}", + ) + + title, body = _route_payload(digest) + if not approved: + return handoff_router.HandoffResult( + success=True, + target=destination, + dry_run=True, + details={ + "approval_required": True, + "message": "Digest remains report-only until routing is explicitly approved.", + }, + ) + + if destination == "followup_queue": + return handoff_router.create_followup_queue_item( + queue_path=queue_path + if queue_path is not None + else os.getenv("WHISPERFORGE_HANDOFF_FOLLOWUP_QUEUE_PATH", ""), + title=title, + body=body, + dry_run=dry_run, + ) + + draft_type = "page" if destination == "notion_page_draft" else "task" + return handoff_router.create_notion_draft( + draft_dir=notion_draft_dir + if notion_draft_dir is not None + else os.getenv("WHISPERFORGE_HANDOFF_NOTION_DRAFT_DIR", ""), + title=title, + body=body, + draft_type=draft_type, + dry_run=dry_run, + ) + + +def _route_payload(digest: dict[str, Any]) -> tuple[str, str]: + generated_at = str(digest.get("generated_at") or "") + day = generated_at[:10] if len(generated_at) >= 10 else "latest" + title = f"WhisperForge resurfacing digest {day}" + body = "\n".join([ + "Approved digest routing payload.", + "", + render_markdown(digest).rstrip(), + "", + ]) + return title, body + + def _run_title(output: dict[str, Any], run_id: str) -> str: article = str(output.get("article") or "").strip() if article: