Skill: nissan/notion-content-pipeline (version 1.0.3)

--- name: notion-content-pipeline version: 1.0.3 metadata: { "openclaw": { "emoji": "📝", "requires": { "bins": ["python3"], "env": ["NOTION_API_KEY"] }, "network": { "outbound": true, "reason": "Syncs markdown content to/from Notion API. No other external calls." } } } description: "Two-way markdown ↔ Notion sync for blog and content workflows. Use when: pushing local .md files to Notion for editing, pulling Notion edits back to local files, managing a content pipeline database with statuses (Seed → Draft → Review → Published), or tracking local file ↔ Notion page mappings. Supports batch push-all, per-file push/pull, and pipeline DB creation with Platform/Status/Hook properties." --- **Last used:** 2026-03-24 **Memory references:** 2 **Status:** Active # notion-content-pipeline Push local `.md` files to Notion and pull edits back. Track a content pipeline DB with statuses (Seed → Draft → Review → Published). --- ## Key Database IDs | Database | ID | |---|---| | **Content Pipeline DB** (blog posts, n8n trigger) | [REDACTED_SECRET] | | **Tweet Pipeline DB** (cron trigger) | [REDACTED_SECRET] | Use the Content Pipeline DB ID when pushing blog posts via the blog-to-social pipeline (status: In Review → Approved). --- ## When to Use This / When NOT to Use This **Use this skill when:** - Pushing a local `.md` draft to Notion for Nissan to review/edit in Notion UI - Pulling Nissan's Notion edits back to a local file - Advancing a post through the content pipeline (Draft → Humanized → In Review) - Creating or querying the Content Pipeline tracking database **Do NOT use this skill when:** - Writing a quick one-off note to Notion — use the Notion API directly with a simple `curl` - Reading from arbitrary Notion databases (this skill is scoped to the content pipeline DB and markdown sync) - Posting to LinkedIn/Twitter — that's the `buffer-publisher` skill - The post hasn't been written yet — draft first, then push **Boundary with direct Notion API calls:** This skill wraps the Notion API with markdown conversion and sync-map tracking. Use it when you need the full sync workflow. For raw Notion API queries (e.g., fetching a database row, reading a page property), calling the API directly with `curl` or `httpx` is simpler and doesn't need this skill. --- ## ⚠️ Credentials — Critical ```bash # CORRECT — use this 1Password item: NOTION_API_KEY=$(op read "op://OpenClaw/bg2gpqhpta6an5n4prn2zzycya/credential") # DO NOT use the old item (dead as of early 2026): # op://OpenClaw/Notion API Key/credential ← this vault item is stale/deleted ``` API version header required: `Notion-Version: 2022-06-28` --- ## Scripts - `scripts/notion_content_sync.py` — push/pull individual files or all at once - `scripts/create_pipeline_db.py` — create a Notion database for content pipeline tracking - `scripts/pipeline_advance.py` — **full round-trip advance**: pull → humanize → fact-check → push → status update ## Configuration ```bash NOTION_API_KEY=secret_... # Notion integration token (from 1Password item above) NOTION_PARENT_PAGE_ID=<uuid> # Parent page ID for sandbox + pipeline DB NOTION_SYNC_MAP=~/.notion_sync_map.json # Where to store file ↔ page ID mapping CONTENT_DIR=./content # Directory containing .md files ``` Or pass `--sandbox-id` and `--sync-map` as CLI flags. ## Quick Start ```bash # Push a single file to Notion python3 scripts/notion_content_sync.py push content/my-post.md # Push all .md files in content/ python3 scripts/notion_content_sync.py push-all # Pull Notion edits back to local file python3 scripts/notion_content_sync.py pull content/my-post.md # List all tracked pages with Notion URLs python3 scripts/notion_content_sync.py list # Create the Content Pipeline database python3 scripts/create_pipeline_db.py ``` --- ## What a Successful Push Looks Like ``` Pushing content/my-post.md → Notion... ✅ Created page: "My Post Title" Notion URL: https://www.notion.so/My-Post-Title-abc123def456... Page ID: abc123de-f456-7890-abcd-ef1234567890 Sync map updated: content/my-post.md → abc123de-f456-7890-abcd-ef1234567890 ``` If you see `✅ Created page` with a Notion URL and the sync map updates, the push succeeded. **Overwrite (push to existing page):** ``` Pushing content/my-post.md → Notion... Archiving existing page abc123de-... (overwrite mode) ✅ Created page: "My Post Title" (fresh) Notion URL: https://www.notion.so/My-Post-Title-xyz789... ``` **Failure indicators:** - `401 Unauthorized` → API key wrong or expired — check the 1Password item - `404 Not Found` on parent page → `NOTION_PARENT_PAGE_ID` is wrong or the page was deleted - `400 Bad Request` with block validation error → likely a markdown parsing issue (see Known Bug below) --- ## Sync Map File ↔ Notion page ID mapping stored in JSON. Example: ```json { "content/my-post.md": "abc123-...", "content/other-post.md": "def456-..." } ``` Default location: `./notion_sync_map.json` (override with `NOTION_SYNC_MAP` env var). ## Overwrite Behaviour On `push`, if the file is already tracked, the existing Notion page is archived and a fresh page is created. This avoids block-count drift from repeated pushes. Use `--no-overwrite` to skip if already pushed. --- ## Known Bug: Annotation Parser (Fixed 2026-03-20) **Bug:** The markdown→Notion block converter incorrectly parsed inline annotations (bold, italic, code spans) when they appeared at the start of a paragraph. This caused a `400 Bad Request` from the Notion API with an error like `"annotations object is invalid"`. **Fix applied 2026-03-20:** The annotation parser now correctly handles leading annotations by initialising the text accumulator before the first annotation span, not after. **If you see annotation-related 400 errors:** Pull the latest version of `scripts/notion_content_sync.py`. If the error persists, check whether the markdown contains unusual Unicode or nested emphasis (`**_bold italic_**`) which may still trip the parser. --- ## Pipeline DB Schema Created by `create_pipeline_db.py`: - **Title** — post title - **Slug** ⚠️ — URL slug used by n8n to publish to reddi.tech (e.g. [REDACTED_SECRET]). **MANDATORY** — n8n reads `props.Slug?.rich_text` and sends it to the publish API. If empty, n8n silently skips the page even when Status = Approved ✅. - **Platform** — Blog / LinkedIn / Both - **Status** — Seed → Draft → Humanized → In Review → Approved ✅ → In Publishing 🚀 → Published - **Hook** — one-sentence pitch - **Est. Read Time** — < 1 min / 2-3 min / 5-7 min / 10+ min - **Published URL** — final URL once live (set by n8n after successful publish) - **PR URL** — GitHub PR URL (set by n8n) - **Source** — where the idea came from ### ⚠️ Slug field — how to set it When pushing a blog post to the Content Pipeline DB, always set the Slug property: ```python "Slug": {"rich_text": [{"type": "text", "text": {"content": slug}}]} ``` Slug format rules: - Lowercase, hyphens only (no underscores, no spaces) - Derived from the post title: strip punctuation, replace spaces with hyphens - Must be unique across all blog posts - n8n uses this to: (1) build the reddi.tech URL, (2) name the MDX file in the repo, (3) create the GitHub PR **Lesson learned 2026-03-28:** The Slug property was missing from the DB schema entirely. n8n ran every 15 minutes, found the Approved page, extracted an empty slug string, and silently skipped publishing. Added Slug as a required field to the DB schema and set it on the page to unblock publishing. --- ## pipeline_advance.py — Automated Round-Trip ```bash # Full advance: pull → humanize → fact-check → push → status python3 scripts/pipeline_advance.py advance content/my-post.md # Skip steps individually python3 scripts/pipeline_advance.py advance content/my-post.md --skip-humanize python3 scripts/pipeline_advance.py advance content/my-post.md --skip-factcheck # Preview without making changes python3 scripts/pipeline_advance.py advance content/my-post.md --dry-run ``` ### What it does 1. **Pulls** Notion edits back to your local `.md` file 2. **Humanizes** — applies mechanical AI-pattern fixes in Python (em dash → comma, "utilize" → "use", filler openers, copula avoidance, curly quotes). Flags rule-of-three and AI-vocab patterns for LLM review. Writes a `.humanizer.diff` alongside the file. 3. **Fact-checks** — runs `skills/fact-checker/scripts/fact_check.py` if available; otherwise skips gracefully. Report saved as `.factcheck.txt`. 4. **Pushes** the humanized file back to Notion (archives old page, creates fresh one). 5. **Updates status** in the Content Pipeline DB: - `Draft` → `Humanized` (after humanize + fact-check) - `Humanized` → `In Review` (after fact-check only) - Never advances past `In Review` — that's Nissan's decision. ### Extra Env Var ```bash NOTION_PIPELINE_DB_ID=322eb552-581a-8111-8f6a-d042dd048ec8 # optional, hardcoded fallback ``` --- ## Common Mistakes 1. **Using the wrong 1Password item for the API key** - ❌ `op://OpenClaw/Notion API Key/credential` → dead item, returns empty or wrong credential - ✅ `op://OpenClaw/bg2gpqhpta6an5n4prn2zzycya/credential` 2. **Missing `Notion-Version` header** - The Notion API requires `Notion-Version: 2022-06-28` on all requests - Scripts include this, but if you're making raw API calls, don't omit it 3. **Parent page not shared with the integration** - If you get 404 on push, check that the Notion integration has access to the parent page - In Notion: open the parent page → Connections → add your integration 4. **Pushing without pulling first (when round-tripping)** - If Nissan edited in Notion and you push without pulling first, you'll overwrite their edits - Always `pull` before `push` if the page may have been edited in Notion 5. **Sync map out of date after manual Notion changes** - If a Notion page was manually deleted, the sync map still references its old ID - The next push will fail with 404, then auto-create a new page and update the map - This is handled gracefully — don't manually edit the sync map JSON 6. **Annotation parser 400 errors on old script versions** - See Known Bug section above — ensure `notion_content_sync.py` is post-2026-03-20 7. **Missing Slug field → n8n silently skips Approved pages** (2026-03-28) - n8n reads `props.Slug?.rich_text` — if the field doesn't exist on the DB or the page, it gets an empty string and skips publishing without error - Always set `Slug` when creating a page in the Content Pipeline DB - If a page is stuck at Approved ✅ and n8n isn't picking it up, check the Slug field first --- ## See Also - `references/api_notes.md` — Notion API quirks and block type reference

path: references/api_notes.md
content: # Notion API Notes

## Critical constraints

- **Notion API version:** `2022-06-28` — use this. The `2025-09-03` version has breaking
  changes to `data_sources` and is not backward compatible.
- **Block limit per request:** 100 blocks max when appending children. Batch in chunks of 100.
- **Rich text length limit:** 2000 chars per rich_text object. Chunk long content to 1900.
- **Rate limits:** ~3 requests/second. Add `time.sleep(0.3)` between batch calls.

## Overwrite pattern

Notion doesn't support replacing all blocks in a page. The correct "overwrite" pattern is:
1. Archive the old page (`PATCH /pages/{id}` with `{"archived": true}`)
2. Create a fresh page as a sibling under the same parent
3. Update the sync map with the new page ID

Do **not** try to delete individual blocks and re-push — too fragile.

## Block types supported

| Markdown     | Notion block type       |
|-------------|-------------------------|
| `# Heading` | `heading_1`             |
| `## Heading`| `heading_2`             |
| `### Heading`| `heading_3`            |
| `> Quote`   | `quote`                 |
| `- Item`    | `bulleted_list_item`    |
| `1. Item`   | `numbered_list_item`    |
| `` ```code ``` `` | `code`            |
| `---`       | `divider`               |
| paragraph   | `paragraph`             |

## Database property types

| Type     | API schema                              |
|----------|-----------------------------------------|
| Title    | `{"title": {}}`                         |
| Text     | `{"rich_text": {}}`                     |
| Select   | `{"select": {"options": [...]}}`        |
| URL      | `{"url": {}}`                           |
| Date     | `{"date": {}}`                          |
| Number   | `{"number": {}}`                        |
| Checkbox | `{"checkbox": {}}`                      |

## Integration setup

1. Go to https://www.notion.so/my-integrations
2. Create integration → copy the token (starts with `secret_`)
3. Open the parent page in Notion → click "..." → "Add connections" → select your integration
4. The integration must be connected to the page/DB to read or write it

## Getting a page ID

From the URL: `https://notion.so/My-Page-{ID-with-dashes}`  
Or: `https://notion.so/{workspace}/{32-char-id}`  
The ID is the last 32 chars (with or without hyphens — both work in the API).


path: scripts/create_pipeline_db.py
content: #!/usr/bin/env python3
"""
create_pipeline_db.py — Create a Content Pipeline Notion database.

Creates a database with columns: Title, Platform, Status, Hook, Est. Read Time,
Published URL, Source, Sprint (or any custom tag column).

Configuration:
  NOTION_API_KEY          Notion integration token (required)
  NOTION_PARENT_PAGE_ID   Parent page ID to create the DB under (required)

Usage:
  python3 create_pipeline_db.py
  python3 create_pipeline_db.py --parent-id <page-id>
  python3 create_pipeline_db.py --seed-file seeds.json  # Optional: seed with initial items
"""
from __future__ import annotations

import argparse
import json
import os
import sys

try:
    import requests
except ImportError:
    print("requests not installed. Run: pip install requests", file=sys.stderr)
    sys.exit(1)

NOTION_VERSION = "2022-06-28"
NOTION_API = "https://api.notion.com/v1"


def get_key() -> str:
    key = os.environ.get("NOTION_API_KEY") or os.environ.get("NOTION_KEY")
    if key:
        return key
    raise RuntimeError("Set NOTION_API_KEY env var. Get one at https://www.notion.so/my-integrations")


def get_parent_id(override: str | None) -> str:
    page_id = override or os.environ.get("NOTION_PARENT_PAGE_ID")
    if not page_id:
        raise RuntimeError("Set NOTION_PARENT_PAGE_ID env var or pass --parent-id")
    return page_id


def headers(key: str) -> dict:
    return {
        "Authorization": f"Bearer {key}",
        "Notion-Version": NOTION_VERSION,
        "Content-Type": "application/json",
    }


def create_database(key: str, parent_page_id: str, db_title: str = "Content Pipeline") -> str:
    """Create the Content Pipeline database. Returns its ID."""
    payload = {
        "parent": {"type": "page_id", "page_id": parent_page_id},
        "icon": {"type": "emoji", "emoji": "✍️"},
        "title": [{"type": "text", "text": {"content": db_title}}],
        "properties": {
            "Title": {"title": {}},
            "Platform": {
                "select": {
                    "options": [
                        {"name": "Blog", "color": "blue"},
                        {"name": "LinkedIn", "color": "green"},
                        {"name": "Both", "color": "purple"},
                        {"name": "Newsletter", "color": "orange"},
                    ]
                }
            },
            "Status": {
                "select": {
                    "options": [
                        {"name": "Seed", "color": "gray"},
                        {"name": "Draft", "color": "yellow"},
                        {"name": "Humanized", "color": "orange"},
                        {"name": "In Review", "color": "blue"},
                        {"name": "Published", "color": "green"},
                    ]
                }
            },
            "Source": {"rich_text": {}},
            "Hook": {"rich_text": {}},
            "Est. Read Time": {
                "select": {
                    "options": [
                        {"name": "< 1 min", "color": "green"},
                        {"name": "2-3 min", "color": "blue"},
                        {"name": "5-7 min", "color": "yellow"},
                        {"name": "10+ min", "color": "red"},
                    ]
                }
            },
            "Published URL": {"url": {}},
            "Tags": {"rich_text": {}},
        },
    }
    r = requests.post(f"{NOTION_API}/databases", headers=headers(key), json=payload)
    if r.status_code != 200:
        print(f"ERROR creating database: {r.status_code}: {r.text[:300]}", file=sys.stderr)
        sys.exit(1)
    db_id = r.json()["id"]
    print(f"✓ Created '{db_title}' database: {db_id}")
    return db_id


def add_page(key: str, db_id: str, title: str, platform: str = "Blog",
             status: str = "Seed", source: str = "", hook: str = "",
             read_time: str = "5-7 min", tags: str = "") -> str | None:
    """Add a page to the pipeline database."""
    payload = {
        "parent": {"database_id": db_id},
        "icon": {"type": "emoji", "emoji": "📝" if platform != "LinkedIn" else "💼"},
        "properties": {
            "Title": {"title": [{"text": {"content": title}}]},
            "Platform": {"select": {"name": platform}},
            "Status": {"select": {"name": status}},
            "Source": {"rich_text": [{"text": {"content": source}}]},
            "Hook": {"rich_text": [{"text": {"content": hook}}]},
            "Est. Read Time": {"select": {"name": read_time}},
            "Tags": {"rich_text": [{"text": {"content": tags}}]},
        },
    }
    r = requests.post(f"{NOTION_API}/pages", headers=headers(key), json=payload)
    if r.status_code != 200:
        print(f"  ERROR adding '{title[:40]}': {r.status_code}: {r.text[:200]}", file=sys.stderr)
        return None
    page_id = r.json()["id"]
    print(f"  ✓ [{platform:10s}] [{status}] {title[:60]}")
    return page_id


def main() -> None:
    parser = argparse.ArgumentParser(description="Create Notion Content Pipeline database")
    parser.add_argument("--parent-id", help="Override NOTION_PARENT_PAGE_ID")
    parser.add_argument("--title", default="Content Pipeline", help="Database title")
    parser.add_argument("--seed-file",
                        help="Path to JSON file with array of {title, platform, status, source, hook, read_time} items to seed")
    args = parser.parse_args()

    key = get_key()
    parent_id = get_parent_id(args.parent_id)

    print(f"=== Creating '{args.title}' database ===")
    db_id = create_database(key, parent_id, args.title)

    if args.seed_file:
        seeds = json.loads(open(args.seed_file).read())
        print(f"\n=== Seeding {len(seeds)} items ===")
        for seed in seeds:
            add_page(
                key=key,
                db_id=db_id,
                title=seed.get("title", "Untitled"),
                platform=seed.get("platform", "Blog"),
                status=seed.get("status", "Seed"),
                source=seed.get("source", ""),
                hook=seed.get("hook", ""),
                read_time=seed.get("read_time", "5-7 min"),
                tags=seed.get("tags", ""),
            )

    print(f"\n✅ Done. Database ID: {db_id}")
    print(f"   Set NOTION_PIPELINE_DB_ID={db_id} in your env to use it in other scripts.")


if __name__ == "__main__":
    main()


path: scripts/notion_content_sync.py
content: #!/usr/bin/env python3
"""Notion Content Sync — push/pull blog drafts between local .md files and Notion.

Usage:
  python3 notion_content_sync.py push content/my-post.md
  python3 notion_content_sync.py push-all
  python3 notion_content_sync.py pull content/my-post.md
  python3 notion_content_sync.py list

Configuration (env vars):
  NOTION_API_KEY        Notion integration token (required)
  NOTION_PARENT_PAGE_ID Parent page ID for the sandbox (required for push/push-all)
  NOTION_SYNC_MAP       Path to sync map JSON (default: notion_sync_map.json)
  CONTENT_DIR           Directory with .md files (default: ./content)

CLI overrides:
  --sandbox-id    Override NOTION_PARENT_PAGE_ID
  --sync-map      Override NOTION_SYNC_MAP path
  --no-overwrite  Don't re-push if already tracked
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Optional

NOTION_VERSION = "2022-06-28"
NOTION_API = "https://api.notion.com/v1"


# ── Config helpers ────────────────────────────────────────────────────────────

def _get_notion_key() -> str:
    key = os.environ.get("NOTION_API_KEY") or os.environ.get("NOTION_KEY")
    if key:
        return key
    raise RuntimeError(
        "No Notion API key found. Set NOTION_API_KEY env var.\n"
        "Get one at https://www.notion.so/my-integrations"
    )


def _get_parent_page_id(override: Optional[str] = None) -> str:
    page_id = override or os.environ.get("NOTION_PARENT_PAGE_ID")
    if not page_id:
        raise RuntimeError(
            "No parent page ID found. Set NOTION_PARENT_PAGE_ID env var or pass --sandbox-id."
        )
    return page_id


def _get_sync_map_path(override: Optional[str] = None) -> Path:
    path = override or os.environ.get("NOTION_SYNC_MAP", "notion_sync_map.json")
    return Path(path)


def _get_content_dir() -> Path:
    return Path(os.environ.get("CONTENT_DIR", "content"))


# ── Notion API helpers ────────────────────────────────────────────────────────

def _headers(key: str) -> dict:
    return {
        "Authorization": f"Bearer {key}",
        "Notion-Version": NOTION_VERSION,
        "Content-Type": "application/json",
    }


def _req(method: str, url: str, key: str, **kwargs) -> dict:
    import requests
    resp = getattr(requests, method)(f"{NOTION_API}{url}", headers=_headers(key), **kwargs)
    if not resp.ok:
        print(f"Notion API error {resp.status_code}: {resp.text[:300]}", file=sys.stderr)
        resp.raise_for_status()
    return resp.json()


def _append_blocks(page_id: str, blocks: list[dict], key: str) -> None:
    """Append blocks in batches of 100 (Notion API limit)."""
    for i in range(0, len(blocks), 100):
        batch = blocks[i:i + 100]
        _req("patch", f"/blocks/{page_id}/children", key,
             json={"children": batch}, timeout=30)
        if i + 100 < len(blocks):
            time.sleep(0.3)


def _archive_page(page_id: str, key: str) -> None:
    _req("patch", f"/pages/{page_id}", key,
         json={"archived": True}, timeout=15)


# ── Markdown → Notion blocks ──────────────────────────────────────────────────

def _rich_text(text: str) -> list[dict]:
    """Parse inline markdown (bold, italic, inline code) into Notion rich_text."""
    if not text:
        return [{"type": "text", "text": {"content": ""}}]

    pattern = re.compile(r'(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|_[^_]+_)')
    parts = pattern.split(text)
    result = []
    for part in parts:
        if not part:
            continue
        ann = {"bold": False, "italic": False, "code": False,
               "strikethrough": False, "underline": False, "color": "default"}
        content = part
        if part.startswith("`") and part.endswith("`") and len(part) > 1:
            content = part[1:-1]
            ann["code"] = True
        elif part.startswith("**") and part.endswith("**") and len(part) > 3:
            content = part[2:-2]
            ann["bold"] = True
        elif ((part.startswith("*") and part.endswith("*") and len(part) > 1) or
              (part.startswith("_") and part.endswith("_") and len(part) > 1)):
            content = part[1:-1]
            ann["italic"] = True
        for chunk in _chunk_text(content, 1900):
            result.append({
                "type": "text",
                "text": {"content": chunk},
                "annotations": ann.copy(),
            })
    return result or [{"type": "text", "text": {"content": text}}]


def _chunk_text(text: str, max_len: int) -> list[str]:
    if len(text) <= max_len:
        return [text]
    chunks = []
    while text:
        chunks.append(text[:max_len])
        text = text[max_len:]
    return chunks


def md_to_blocks(markdown: str) -> list[dict]:
    """Convert markdown string to Notion block objects."""
    blocks: list[dict] = []
    lines = markdown.splitlines()
    i = 0

    while i < len(lines):
        line = lines[i]

        if line.strip().startswith("```"):
            lang = line.strip()[3:].strip() or "plain text"
            code_lines = []
            i += 1
            while i < len(lines) and not lines[i].strip().startswith("```"):
                code_lines.append(lines[i])
                i += 1
            code_body = "\n".join(code_lines)
            for chunk in _chunk_text(code_body, 1900):
                blocks.append({
                    "object": "block", "type": "code",
                    "code": {
                        "language": lang,
                        "rich_text": [{"type": "text", "text": {"content": chunk}}],
                    },
                })
            i += 1
            continue

        if re.match(r"^-{3,}$", line.strip()):
            blocks.append({"object": "block", "type": "divider", "divider": {}})
            i += 1
            continue

        m = re.match(r"^(#{1,3})\s+(.*)", line)
        if m:
            level = len(m.group(1))
            text = m.group(2).strip()
            btype = f"heading_{level}"
            blocks.append({
                "object": "block", "type": btype,
                btype: {"rich_text": _rich_text(text)},
            })
            i += 1
            continue

        if line.startswith("> "):
            blocks.append({
                "object": "block", "type": "quote",
                "quote": {"rich_text": _rich_text(line[2:])},
            })
            i += 1
            continue

        m = re.match(r"^[-*]\s+(.*)", line)
        if m:
            blocks.append({
                "object": "block", "type": "bulleted_list_item",
                "bulleted_list_item": {"rich_text": _rich_text(m.group(1))},
            })
            i += 1
            continue

        m = re.match(r"^\d+\.\s+(.*)", line)
        if m:
            blocks.append({
                "object": "block", "type": "numbered_list_item",
                "numbered_list_item": {"rich_text": _rich_text(m.group(1))},
            })
            i += 1
            continue

        if not line.strip():
            i += 1
            continue

        # Paragraph (accumulate until blank line or structural element)
        para_lines = []
        while i < len(lines) and lines[i].strip() and \
              not lines[i].strip().startswith("#") and \
              not lines[i].strip().startswith("```") and \
              not lines[i].strip().startswith("---") and \
              not re.match(r"^[-*]\s", lines[i]) and \
              not re.match(r"^\d+\.\s", lines[i]) and \
              not lines[i].startswith("> "):
            para_lines.append(lines[i])
            i += 1

        text = " ".join(para_lines)
        if text.strip():
            for chunk in _chunk_text(text, 1900):
                blocks.append({
                    "object": "block", "type": "paragraph",
                    "paragraph": {"rich_text": _rich_text(chunk)},
                })

    return blocks


# ── Notion → Markdown ─────────────────────────────────────────────────────────

def _rt_to_md(rich_text: list[dict]) -> str:
    result = []
    for rt in rich_text:
        content = rt.get("text", {}).get("content", "")
        ann = rt.get("annotations", {})
        if ann.get("code"):
            content = f"`{content}`"
        if ann.get("bold"):
            content = f"**{content}**"
        if ann.get("italic"):
            content = f"_{content}_"
        result.append(content)
    return "".join(result)


def blocks_to_md(blocks: list[dict]) -> str:
    lines = []
    for block in blocks:
        btype = block.get("type", "")
        content = block.get(btype, {})
        if btype == "heading_1":
            lines.append(f"# {_rt_to_md(content.get('rich_text', []))}")
        elif btype == "heading_2":
            lines.append(f"## {_rt_to_md(content.get('rich_text', []))}")
        elif btype == "heading_3":
            lines.append(f"### {_rt_to_md(content.get('rich_text', []))}")
        elif btype == "paragraph":
            text = _rt_to_md(content.get("rich_text", []))
            lines.append(text if text.strip() else "")
        elif btype == "bulleted_list_item":
            lines.append(f"- {_rt_to_md(content.get('rich_text', []))}")
        elif btype == "numbered_list_item":
            lines.append(f"1. {_rt_to_md(content.get('rich_text', []))}")
        elif btype == "code":
            lang = content.get("language", "")
            code = _rt_to_md(content.get("rich_text", []))
            lines.append(f"```{lang}\n{code}\n```")
        elif btype == "divider":
            lines.append("---")
        elif btype == "quote":
            lines.append(f"> {_rt_to_md(content.get('rich_text', []))}")
        lines.append("")
    return "\n".join(lines).strip() + "\n"


# ── Sync map helpers ──────────────────────────────────────────────────────────

def _load_map(sync_map_path: Path) -> dict:
    if sync_map_path.exists():
        return json.loads(sync_map_path.read_text())
    return {}


def _save_map(m: dict, sync_map_path: Path) -> None:
    sync_map_path.write_text(json.dumps(m, indent=2))


# ── Commands ──────────────────────────────────────────────────────────────────

def cmd_push(file_path: Path, key: str, sandbox_id: str,
             sync_map_path: Path, overwrite: bool = True) -> str:
    sync_map = _load_map(sync_map_path)
    rel = str(file_path)
    md = file_path.read_text()

    title = file_path.stem.replace("-", " ").title()
    for line in md.splitlines():
        if line.startswith("# "):
            title = line[2:].strip()
            break

    blocks = md_to_blocks(md)
    existing_id = sync_map.get(rel)

    if existing_id and overwrite:
        print(f"  Archiving existing page {existing_id}, creating fresh...")
        _archive_page(existing_id, key)
        time.sleep(0.5)

    page = _req("post", "/pages", key, json={
        "parent": {"page_id": sandbox_id},
        "properties": {
            "title": {"title": [{"text": {"content": title}}]},
        },
        "children": blocks[:100],
    }, timeout=30)
    page_id = page["id"]
    if len(blocks) > 100:
        _append_blocks(page_id, blocks[100:], key)
    print(f"  Created {len(blocks)} blocks → page {page_id}")

    sync_map[rel] = page_id
    _save_map(sync_map, sync_map_path)
    return page_id


def cmd_pull(file_path: Path, key: str, sync_map_path: Path) -> None:
    sync_map = _load_map(sync_map_path)
    rel = str(file_path)
    page_id = sync_map.get(rel)
    if not page_id:
        print(f"No tracked Notion page for {rel}. Run push first.", file=sys.stderr)
        sys.exit(1)

    all_blocks = []
    cursor = None
    while True:
        params = {"page_size": 100}
        if cursor:
            params["start_cursor"] = cursor
        data = _req("get", f"/blocks/{page_id}/children", key,
                    params=params, timeout=15)
        all_blocks.extend(data.get("results", []))
        if not data.get("has_more"):
            break
        cursor = data["next_cursor"]

    md = blocks_to_md(all_blocks)
    file_path.write_text(md)
    print(f"  Pulled {len(all_blocks)} blocks → {file_path}")


def cmd_list(sync_map_path: Path) -> None:
    sync_map = _load_map(sync_map_path)
    if not sync_map:
        print("No tracked pages yet.")
        return
    for rel, page_id in sync_map.items():
        url = f"https://notion.so/{page_id.replace('-', '')}"
        print(f"  {rel}")
        print(f"    ID:  {page_id}")
        print(f"    URL: {url}")


def cmd_push_all(key: str, sandbox_id: str, sync_map_path: Path,
                 content_dir: Path) -> None:
    files = sorted(content_dir.glob("*.md"))
    if not files:
        print(f"No .md files found in {content_dir}")
        return
    for f in files:
        print(f"\nPushing {f.name}...")
        page_id = cmd_push(f, key, sandbox_id, sync_map_path)
        url = f"https://notion.so/{page_id.replace('-', '')}"
        print(f"  → {url}")


# ── CLI ───────────────────────────────────────────────────────────────────────

def main() -> None:
    parser = argparse.ArgumentParser(description="Notion ↔ Markdown content sync")
    parser.add_argument("command", choices=["push", "push-all", "pull", "list"])
    parser.add_argument("file", nargs="?", help="Path to .md file")
    parser.add_argument("--sandbox-id", help="Override NOTION_PARENT_PAGE_ID")
    parser.add_argument("--sync-map", help="Override NOTION_SYNC_MAP path")
    parser.add_argument("--no-overwrite", action="store_true")
    args = parser.parse_args()

    key = _get_notion_key()
    sync_map_path = _get_sync_map_path(args.sync_map)
    content_dir = _get_content_dir()

    if args.command == "list":
        cmd_list(sync_map_path)

    elif args.command == "push-all":
        sandbox_id = _get_parent_page_id(args.sandbox_id)
        cmd_push_all(key, sandbox_id, sync_map_path, content_dir)

    elif args.command == "push":
        if not args.file:
            parser.error("push requires a file argument")
        f = Path(args.file)
        if not f.exists():
            print(f"File not found: {f}", file=sys.stderr)
            sys.exit(1)
        sandbox_id = _get_parent_page_id(args.sandbox_id)
        page_id = cmd_push(f, key, sandbox_id, sync_map_path,
                            overwrite=not args.no_overwrite)
        url = f"https://notion.so/{page_id.replace('-', '')}"
        print(f"  → {url}")

    elif args.command == "pull":
        if not args.file:
            parser.error("pull requires a file argument")
        cmd_pull(Path(args.file), key, sync_map_path)


if __name__ == "__main__":
    main()


path: scripts/pipeline_advance.py
content: #!/usr/bin/env python3
"""pipeline_advance.py — Advance a blog post through the content pipeline.

Automates the round-trip for a single blog post:
  1. Pull Nissan's edits from Notion → local markdown
  2. Humanize (mechanical patterns in Python + flags LLM pass needed)
  3. Fact-check (via skills/fact-checker/scripts/fact_check.py if available)
  4. Push humanized version back to Notion
  5. Update status in Content Pipeline DB

Usage:
  python3 pipeline_advance.py advance content/blog-post.md [--skip-humanize]
      [--skip-factcheck] [--dry-run]

Environment:
  NOTION_API_KEY        Notion integration token (required)
  NOTION_PARENT_PAGE_ID Parent page for sandbox content (required)
  NOTION_SYNC_MAP       Path to sync map JSON (default: notion_sync_map.json)
  CONTENT_DIR           Content directory (default: ./content)
  NOTION_PIPELINE_DB_ID Pipeline DB ID (falls back to hardcoded constant)
"""
from __future__ import annotations

import argparse
import difflib
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional

# ── Resolve workspace root and sibling scripts ────────────────────────────────

_SCRIPTS_DIR = Path(__file__).resolve().parent
_SKILL_DIR   = _SCRIPTS_DIR.parent
_WORKSPACE   = _SKILL_DIR.parent.parent   # workspace/skills/<skill>/scripts → workspace

# Add scripts dir so we can import notion_content_sync directly
sys.path.insert(0, str(_SCRIPTS_DIR))

from notion_content_sync import (   # noqa: E402
    _get_notion_key,
    _get_parent_page_id,
    _get_sync_map_path,
    _req,
    cmd_pull,
    cmd_push,
)

# ── Constants ─────────────────────────────────────────────────────────────────

PIPELINE_DB_ID = os.environ.get(
    "NOTION_PIPELINE_DB_ID", "312c9a82-0734-81bd-81a6-e58d0365e404"
)

FACT_CHECKER_SCRIPT = _WORKSPACE / "skills" / "fact-checker" / "scripts" / "fact_check.py"
PYTHON = "/Users/loki/.pyenv/versions/3.14.3/bin/python3"

# Status machine — only these two automatic transitions are allowed
STATUS_TRANSITIONS: dict[str, str] = {
    "Draft":     "Humanized",
    "Humanized": "In Review",
}

# ── Mechanical humanizer patterns ─────────────────────────────────────────────

# (compiled_regex, replacement_string)
# Replacements are intentionally conservative — only clear one-to-one swaps.
_WORD_REPLACEMENTS: list[tuple[re.Pattern, str]] = [
    # Filler sentence openers
    (re.compile(r"^Additionally,\s+", re.MULTILINE), "Also, "),
    (re.compile(r"^Moreover,\s+",     re.MULTILINE), "Also, "),
    (re.compile(r"^Furthermore,\s+",  re.MULTILINE), "Also, "),
    # Verbose constructions
    (re.compile(r"\bIn order to\b",              re.IGNORECASE), "To"),
    (re.compile(r"\bdue to the fact that\b",     re.IGNORECASE), "because"),
    (re.compile(r"\bat this point in time\b",    re.IGNORECASE), "now"),
    (re.compile(r"[Ii]t is important to note that\s*"),           ""),
    (re.compile(r"[Ii]t's important to note that\s*"),            ""),
    # Copula avoidance
    (re.compile(r"\bserves as\b",  re.IGNORECASE), "is"),
    (re.compile(r"\bstands as\b",  re.IGNORECASE), "is"),
    # Heavy AI vocabulary (safe swaps only)
    (re.compile(r"\butilize\b",    re.IGNORECASE), "use"),
    (re.compile(r"\butilization\b",re.IGNORECASE), "use"),
    (re.compile(r"\bleverage\b(?!\s+\w+\s+(?:ratio|point|buy))",
                re.IGNORECASE),                   "use"),
    (re.compile(r"\bdelve\b",      re.IGNORECASE), "explore"),
    (re.compile(r"\bshowcase\b",   re.IGNORECASE), "show"),
    (re.compile(r"\bboasts\b",     re.IGNORECASE), "has"),
    (re.compile(r"\bboast\b",      re.IGNORECASE), "have"),
]

# Em dash → comma (but not inside code blocks)
_EM_DASH_RE = re.compile(r"\s*—\s*")

# Curly quotes → straight quotes
_CURLY_OPEN_RE  = re.compile(r"[\u201c\u2018]")
_CURLY_CLOSE_RE = re.compile(r"[\u201d\u2019]")

# Rule of three detector (flag only, not auto-fixed)
_RULE_OF_THREE_RE = re.compile(
    r"\b(?:\w[\w\s]*),\s+(?:\w[\w\s]*),\s+and\s+(?:\w[\w\s]*)\b"
)

# AI vocabulary words that appear but can't be auto-swapped safely — just flag
_AI_VOCAB_FLAG = [
    "pivotal", "crucial", "vibrant", "tapestry", "landscape",
    "testament", "underscore", "highlight", "interplay", "intricate",
    "garner", "foster", "cultivate", "encompass", "groundbreaking",
    "renowned", "breathtaking", "nestled", "enduring", "indelible",
]
_AI_VOCAB_RE = re.compile(
    r"\b(" + "|".join(re.escape(w) for w in _AI_VOCAB_FLAG) + r")\b",
    re.IGNORECASE,
)


def _split_code_blocks(text: str) -> list[tuple[str, bool]]:
    """Split text into (segment, is_code) pairs to skip code blocks."""
    parts: list[tuple[str, bool]] = []
    pattern = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
    last = 0
    for m in pattern.finditer(text):
        if m.start() > last:
            parts.append((text[last:m.start()], False))
        parts.append((m.group(0), True))
        last = m.end()
    if last < len(text):
        parts.append((text[last:], False))
    return parts


def humanize(text: str) -> tuple[str, dict]:
    """Apply mechanical humanizer fixes.

    Returns (new_text, stats) where stats contains:
      - fixes_applied: int
      - rule_of_three_count: int
      - ai_vocab_flagged: list[str]
      - llm_pass_recommended: bool
    """
    stats: dict = {
        "fixes_applied": 0,
        "rule_of_three_count": 0,
        "ai_vocab_flagged": [],
        "llm_pass_recommended": False,
    }

    segments = _split_code_blocks(text)
    new_segments: list[str] = []

    for segment, is_code in segments:
        if is_code:
            new_segments.append(segment)
            continue

        s = segment

        # Em dash → comma/space
        em_count = len(_EM_DASH_RE.findall(s))
        s = _EM_DASH_RE.sub(", ", s)
        stats["fixes_applied"] += em_count

        # Curly quotes → straight quotes
        curly = len(_CURLY_OPEN_RE.findall(s)) + len(_CURLY_CLOSE_RE.findall(s))
        s = _CURLY_OPEN_RE.sub('"', s)
        s = _CURLY_CLOSE_RE.sub('"', s)
        stats["fixes_applied"] += curly

        # Word-level replacements
        for pattern, replacement in _WORD_REPLACEMENTS:
            new_s, n = pattern.subn(replacement, s)
            if n:
                stats["fixes_applied"] += n
                s = new_s

        # Rule of three detection (flag only)
        rot = _RULE_OF_THREE_RE.findall(s)
        stats["rule_of_three_count"] += len(rot)

        # AI vocabulary flagging
        ai_found = [m.group(0).lower() for m in _AI_VOCAB_RE.finditer(s)]
        stats["ai_vocab_flagged"].extend(ai_found)

        new_segments.append(s)

    result = "".join(new_segments)

    # Deduplicate flagged vocab
    stats["ai_vocab_flagged"] = sorted(set(stats["ai_vocab_flagged"]))

    # Recommend LLM pass if there's non-mechanical work to do
    if stats["rule_of_three_count"] > 0 or stats["ai_vocab_flagged"]:
        stats["llm_pass_recommended"] = True

    return result, stats


def _make_diff(original: str, revised: str, file_label: str) -> str:
    """Produce a unified diff string."""
    diff = difflib.unified_diff(
        original.splitlines(keepends=True),
        revised.splitlines(keepends=True),
        fromfile=f"{file_label} (original)",
        tofile=f"{file_label} (humanized)",
    )
    return "".join(diff)


# ── Fact-checker ──────────────────────────────────────────────────────────────

def run_factcheck(file_path: Path) -> Optional[str]:
    """Run fact_check.py if available. Returns report string or None."""
    if not FACT_CHECKER_SCRIPT.exists():
        return None

    result = subprocess.run(
        [PYTHON, str(FACT_CHECKER_SCRIPT), str(file_path)],
        capture_output=True,
        text=True,
        timeout=120,
    )
    output = result.stdout
    if result.stderr:
        output += f"\n[stderr]: {result.stderr[:200]}"
    return output.strip() if output.strip() else "(no output)"


def _parse_factcheck_summary(report: str) -> str:
    """Extract a one-line summary from fact-check report for the summary printout."""
    if report is None:
        return f"⚠️  Skipped (fact-checker not found at {FACT_CHECKER_SCRIPT})"
    # Try to find a summary line — fallback to first line
    for line in report.splitlines():
        if re.search(r"claim|verif|check", line, re.I):
            return line.strip()
    return report.splitlines()[0].strip() if report else "(empty report)"


# ── Pipeline DB helpers ───────────────────────────────────────────────────────

def lookup_pipeline_page(
    title: str, db_id: str, key: str
) -> tuple[Optional[str], Optional[str]]:
    """Find a pipeline DB entry by title. Returns (page_id, status) or (None, None)."""
    try:
        data = _req("post", f"/databases/{db_id}/query", key,
                    json={
                        "filter": {
                            "property": "Title",
                            "title": {"equals": title},
                        }
                    },
                    timeout=15)
    except Exception as e:
        print(f"  ⚠️  Could not query pipeline DB: {e}", file=sys.stderr)
        return None, None

    results = data.get("results", [])
    if not results:
        return None, None

    page = results[0]
    page_id = page["id"]
    status_prop = page.get("properties", {}).get("Status", {})
    status = None
    if status_prop.get("type") == "select" and status_prop.get("select"):
        status = status_prop["select"]["name"]
    return page_id, status


def update_pipeline_status(
    page_id: str, new_status: str, key: str, dry_run: bool = False
) -> None:
    """Update the Status property of a pipeline DB page."""
    if dry_run:
        print(f"  [dry-run] Would PATCH /pages/{page_id} → Status: {new_status}")
        return
    _req("patch", f"/pages/{page_id}", key,
         json={"properties": {"Status": {"select": {"name": new_status}}}},
         timeout=15)


def _derive_title(md: str, file_path: Path) -> str:
    """Derive post title from markdown H1 or filename (matches cmd_push logic)."""
    for line in md.splitlines():
        if line.startswith("# "):
            return line[2:].strip()
    return file_path.stem.replace("-", " ").title()


# ── Main advance command ──────────────────────────────────────────────────────

def cmd_advance(
    file_path: Path,
    skip_humanize: bool = False,
    skip_factcheck: bool = False,
    dry_run: bool = False,
) -> None:
    """Full pipeline advance: pull → humanize → fact-check → push → status update."""
    key         = _get_notion_key()
    sandbox_id  = _get_parent_page_id()
    sync_map_path = _get_sync_map_path(
        os.environ.get("NOTION_SYNC_MAP",
                       str(_WORKSPACE / "scripts" / "notion_sync_map.json"))
    )

    print(f"\n📄 {file_path}")

    # ── 1. Pull ───────────────────────────────────────────────────────────────
    print("\n  [1/5] Pulling from Notion…")
    if not dry_run:
        cmd_pull(file_path, key, sync_map_path)
    else:
        print("  [dry-run] Would pull from Notion")
        if not file_path.exists():
            print(f"  [dry-run] File does not exist yet: {file_path}")
            return

    original_md = file_path.read_text(encoding="utf-8") if file_path.exists() else ""
    word_count  = len(original_md.split())
    print(f"  Pulled from Notion: {word_count:,} words")

    # Derive title for pipeline DB lookup
    title = _derive_title(original_md, file_path)

    # ── 2. Humanize ───────────────────────────────────────────────────────────
    humanizer_summary = "Skipped (--skip-humanize)"
    diff_path: Optional[Path] = None

    if not skip_humanize:
        print("\n  [2/5] Humanizing…")
        humanized_md, h_stats = humanize(original_md)
        n_fixes = h_stats["fixes_applied"]

        diff_str = _make_diff(original_md, humanized_md, str(file_path))
        if diff_str:
            diff_path = file_path.with_suffix(".humanizer.diff")
            if not dry_run:
                diff_path.write_text(diff_str, encoding="utf-8")
                file_path.write_text(humanized_md, encoding="utf-8")
            else:
                print(f"  [dry-run] Would write humanized text and diff → {diff_path}")
        else:
            humanized_md = original_md  # no changes

        # Build summary line
        parts = [f"{n_fixes} mechanical pattern{'s' if n_fixes != 1 else ''} fixed"]
        if diff_path:
            parts.append(f"diff → {diff_path.name}")
        if h_stats["rule_of_three_count"]:
            parts.append(f"{h_stats['rule_of_three_count']} rule-of-three instance(s) flagged")
        if h_stats["ai_vocab_flagged"]:
            flagged = ", ".join(h_stats["ai_vocab_flagged"][:6])
            extra = f" +{len(h_stats['ai_vocab_flagged']) - 6} more" if len(h_stats["ai_vocab_flagged"]) > 6 else ""
            parts.append(f"AI vocab flagged: {flagged}{extra}")
        if h_stats["llm_pass_recommended"]:
            parts.append("⚠️  LLM humanizer pass recommended")

        humanizer_summary = " | ".join(parts)
        print(f"  Humanizer: {humanizer_summary}")
    else:
        print("\n  [2/5] Humanize: skipped")
        humanized_md = original_md

    # ── 3. Fact-check ─────────────────────────────────────────────────────────
    factcheck_summary = "Skipped (--skip-factcheck)"

    if not skip_factcheck:
        print("\n  [3/5] Fact-checking…")
        report = run_factcheck(file_path)
        factcheck_summary = _parse_factcheck_summary(report)
        if report is None:
            print(f"  Fact-check: {factcheck_summary}")
        else:
            report_path = file_path.with_suffix(".factcheck.txt")
            if not dry_run:
                report_path.write_text(report, encoding="utf-8")
            print(f"  Fact-check: {factcheck_summary}")
            print(f"  Full report → {report_path.name}")
    else:
        print("\n  [3/5] Fact-check: skipped")

    # ── 4. Push ───────────────────────────────────────────────────────────────
    print("\n  [4/5] Pushing to Notion…")
    pushed_page_id: Optional[str] = None

    if not dry_run:
        pushed_page_id = cmd_push(file_path, key, sandbox_id, sync_map_path, overwrite=True)
        page_url = f"https://notion.so/{pushed_page_id.replace('-', '')}"
        print(f"  Pushed to Notion: page {pushed_page_id}")
        print(f"  URL: {page_url}")
    else:
        print("  [dry-run] Would push to Notion")

    # ── 5. Update status ──────────────────────────────────────────────────────
    print("\n  [5/5] Updating pipeline status…")
    pipeline_page_id, current_status = lookup_pipeline_page(title, PIPELINE_DB_ID, key)

    status_line = "N/A"
    if pipeline_page_id is None:
        print(f"  ⚠️  No pipeline DB entry found for title: '{title}'")
        print(f"      Run: python3 create_pipeline_db.py and add '{title}' manually.")
        status_line = "No pipeline entry found"
    else:
        new_status = STATUS_TRANSITIONS.get(current_status or "")
        if new_status:
            update_pipeline_status(pipeline_page_id, new_status, key, dry_run=dry_run)
            status_line = f"{current_status} → {new_status}"
            if dry_run:
                status_line += " (dry-run)"
            print(f"  Status: {status_line}")
        elif current_status == "In Review":
            print("  ℹ️  Status is already 'In Review' — Nissan's decision to advance.")
            status_line = "In Review (no change — Nissan's call)"
        else:
            print(f"  ⚠️  Unknown status '{current_status}' — no automatic transition defined.")
            status_line = f"{current_status or 'unknown'} (no transition)"

    # ── Summary ───────────────────────────────────────────────────────────────
    print("\n" + "─" * 60)
    print(f"📄 {file_path}")
    print(f"   Pulled from Notion:  {word_count:,} words")
    print(f"   Humanizer:           {humanizer_summary}")
    print(f"   Fact-check:          {factcheck_summary}")
    if pushed_page_id:
        print(f"   Pushed to Notion:    page {pushed_page_id}")
    elif dry_run:
        print(f"   Pushed to Notion:    [dry-run]")
    print(f"   Status:              {status_line}")
    print("─" * 60)


# ── CLI ───────────────────────────────────────────────────────────────────────

def main() -> None:
    parser = argparse.ArgumentParser(
        description="Advance a blog post through the content pipeline",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    sub = parser.add_subparsers(dest="command")

    adv = sub.add_parser("advance", help="Pull → humanize → fact-check → push → status")
    adv.add_argument("file", help="Path to local .md file")
    adv.add_argument("--skip-humanize",  action="store_true", help="Skip humanizer step")
    adv.add_argument("--skip-factcheck", action="store_true", help="Skip fact-check step")
    adv.add_argument("--dry-run",        action="store_true",
                     help="Show what would happen without making changes")

    args = parser.parse_args()

    if args.command == "advance":
        file_path = Path(args.file)
        if not file_path.exists() and not args.dry_run:
            # Still allow dry-run even if file doesn't exist yet
            print(f"⚠️  File not found: {file_path}", file=sys.stderr)
            sys.exit(1)
        cmd_advance(
            file_path=file_path,
            skip_humanize=args.skip_humanize,
            skip_factcheck=args.skip_factcheck,
            dry_run=args.dry_run,
        )
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal:  | SkillSpector: suspicious (HIGH)