Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down
53 changes: 53 additions & 0 deletions scripts/resurfacing_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import argparse
import os
import sys
from pathlib import Path

Expand All @@ -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())
69 changes: 67 additions & 2 deletions tests/test_handoff_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand All @@ -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,
}


Expand Down Expand Up @@ -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")
82 changes: 82 additions & 0 deletions tests/test_resurfacing.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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")
Expand Down
73 changes: 71 additions & 2 deletions whisperforge_core/handoff_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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"
Loading
Loading