# Notion Writer — Extended Block Coverage Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend `notion_writer.py`'s markdown→Notion block parser to handle GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced divs, and inline `@[Title](id-or-url)` page mentions.

**Architecture:** Reentrant flat parser. The existing `markdown_to_notion_blocks` becomes the public entry that dispatches to a private `_parse_lines(lines)` engine. Container detectors (toggle, columns) extract their inner lines and recurse through `_parse_lines` to produce nested children. Callouts emit a single rich-text run (no recursion). Page mentions extend `INLINE_PATTERNS`. Two depth ints track nested same-kind containers.

**Tech Stack:** Python 3.11+, regex line scanner, `notion-client` v3 SDK (no API calls during parse). Tests run via `python test_parser.py` (plain function-style asserts, no pytest).

**Spec:** `docs/superpowers/specs/2026-04-27-notion-writer-extended-block-coverage-design.md`

---

## File Structure

| File | Purpose | Change |
|---|---|---|
| `custom-skills/32-notion-writer/code/scripts/notion_writer.py` | Main parser + Notion API client | Refactor entry to reentrant; add 3 block factories; add 3 detector branches; add 1 inline pattern + emit branch; add `ALERT_TYPES`/`ALERT_RE` constants |
| `custom-skills/32-notion-writer/code/scripts/test_parser.py` | Parser test suite (currently 16 tests) | +16 tests, total 32 |
| `custom-skills/32-notion-writer/code/CLAUDE.md` | Skill documentation | Update Markdown Support tables + version footer 1.1.0 → 1.2.0 |

No new files. No new dependencies.

---

## Task 1: Make parser reentrant (no behavior change)

**Files:**
- Modify: `custom-skills/32-notion-writer/code/scripts/notion_writer.py:75-157`

The current `markdown_to_notion_blocks(markdown_text: str)` is the only entry. We split it into a public reentrant entry + a private `_parse_lines(lines)` engine. All existing tests must continue to pass with no changes.

- [ ] **Step 1: Run the existing test suite to establish baseline**

```bash
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py
```

Expected: `✅ All 16 tests passed`

- [ ] **Step 2: Widen import to include `Union`**

In `notion_writer.py` at line 12, change:

```python
from typing import Optional, List, Dict, Any
```

to:

```python
from typing import Optional, List, Dict, Any, Union
```

- [ ] **Step 3: Replace the entry function with reentrant form**

Replace the entire body of `markdown_to_notion_blocks` (line 75 through line 157) with the following:

```python
def markdown_to_notion_blocks(content: Union[str, List[str]]) -> List[Dict[str, Any]]:
    """Convert markdown text (or pre-split lines) to Notion block objects.

    Reentrant: container detectors recursively call _parse_lines on inner content.
    """
    if isinstance(content, str):
        lines = content.split('\n')
    else:
        lines = list(content)
    return _parse_lines(lines)


def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]:
    """Walk lines and emit Notion blocks. Called recursively by container detectors."""
    blocks = []
    i = 0

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

        # Skip empty lines
        if not line.strip():
            i += 1
            continue

        # Table (header row + separator + body rows)
        if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
            header_cells = _split_table_row(line)
            i += 2  # skip separator
            body_rows: List[List[str]] = []
            while i < len(lines) and _is_table_row(lines[i]):
                body_rows.append(_split_table_row(lines[i]))
                i += 1
            blocks.append(create_table_block(header_cells, body_rows))
            continue

        # Headers
        if line.startswith('######'):
            blocks.append(create_heading_block(line[6:].strip(), 3))
        elif line.startswith('#####'):
            blocks.append(create_heading_block(line[5:].strip(), 3))
        elif line.startswith('####'):
            blocks.append(create_heading_block(line[4:].strip(), 3))
        elif line.startswith('###'):
            blocks.append(create_heading_block(line[3:].strip(), 3))
        elif line.startswith('##'):
            blocks.append(create_heading_block(line[2:].strip(), 2))
        elif line.startswith('#'):
            blocks.append(create_heading_block(line[1:].strip(), 1))

        # Code blocks
        elif line.startswith('```'):
            language = line[3:].strip() or 'plain text'
            code_lines = []
            i += 1
            while i < len(lines) and not lines[i].startswith('```'):
                code_lines.append(lines[i])
                i += 1
            blocks.append(create_code_block('\n'.join(code_lines), language))

        # Checkbox / Todo (must come before generic bullet match)
        elif line.strip().startswith('- [ ]'):
            text = line.strip()[5:].strip()
            blocks.append(create_todo_block(text, False))
        elif line.strip().startswith('- [x]') or line.strip().startswith('- [X]'):
            text = line.strip()[5:].strip()
            blocks.append(create_todo_block(text, True))

        # Bullet list
        elif line.strip().startswith('- ') or line.strip().startswith('* '):
            text = line.strip()[2:]
            blocks.append(create_bulleted_list_block(text))

        # Numbered list
        elif re.match(r'^\d+\.\s', line.strip()):
            text = re.sub(r'^\d+\.\s', '', line.strip())
            blocks.append(create_numbered_list_block(text))

        # Blockquote
        elif line.startswith('>'):
            text = line[1:].strip()
            blocks.append(create_quote_block(text))

        # Horizontal rule
        elif line.strip() in ['---', '***', '___']:
            blocks.append(create_divider_block())

        # Regular paragraph
        else:
            blocks.append(create_paragraph_block(line))

        i += 1

    return blocks
```

- [ ] **Step 4: Run existing test suite to verify no regression**

```bash
python3 test_parser.py
```

Expected: `✅ All 16 tests passed`

- [ ] **Step 5: Verify the reentrant signature works (sanity check)**

```bash
python3 -c "
from notion_writer import markdown_to_notion_blocks
str_result = markdown_to_notion_blocks('# Hello')
list_result = markdown_to_notion_blocks(['# Hello'])
assert str_result == list_result, 'str and list inputs must produce identical output'
print('Reentrant signature works:', len(str_result), 'block(s)')
"
```

Expected: `Reentrant signature works: 1 block(s)`

- [ ] **Step 6: Commit**

```bash
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py
git commit -m "$(cat <<'EOF'
refactor(notion-writer): make markdown_to_notion_blocks reentrant

Split the entry function into a public reentrant entry that accepts
either string or List[str], and a private _parse_lines engine that
container detectors will recurse into. No behavior change for existing
callers; all 16 parser tests still pass.

Prep for Phase 3c: callout/toggle/columns/page-mention block coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Add GitHub-alert callout blocks

**Files:**
- Modify: `custom-skills/32-notion-writer/code/scripts/notion_writer.py` (add constants, add factory, add detector branch)
- Modify: `custom-skills/32-notion-writer/code/scripts/test_parser.py` (add 6 tests)

GitHub-style alerts (`> [!NOTE]`, `> [!TIP]`, etc.) emit a Notion `callout` block with an emoji icon and a colored background. Body is collected from contiguous `>` lines below the alert marker. Unknown alert types fall through to the existing quote handler.

- [ ] **Step 1: Write the first failing test (`test_callout_note`)**

Open `test_parser.py` and add this test function before the `run_all()` function:

```python
def test_callout_note():
    md = "> [!NOTE]\n> This is a note.\n> Spans multiple lines."
    blocks = markdown_to_notion_blocks(md)
    _assert(len(blocks) == 1, "exactly one block emitted")
    _assert(blocks[0]["type"] == "callout", "block type is callout")
    callout = blocks[0]["callout"]
    _assert(callout["icon"] == {"type": "emoji", "emoji": "ℹ️"}, "NOTE icon is ℹ️")
    _assert(callout["color"] == "blue_background", "NOTE color is blue_background")
    body_text = "".join(s["text"]["content"] for s in callout["rich_text"])
    _assert("This is a note." in body_text, "body line 1 preserved")
    _assert("Spans multiple lines." in body_text, "body line 2 preserved")
```

Add `test_callout_note` to the `tests` list in `run_all()` (insert after `test_blocks_table`):

```python
    tests = [
        # ... existing tests ...
        test_blocks_table,
        test_callout_note,
        test_rich_text_anchor_link_becomes_bold,
        # ... rest unchanged ...
    ]
```

- [ ] **Step 2: Run the new test to verify it fails**

```bash
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | grep -A 2 "test_callout_note"
```

Expected: FAIL — block type is "quote", not "callout".

- [ ] **Step 3: Add `ALERT_TYPES` and `ALERT_RE` constants**

In `notion_writer.py`, add these constants immediately after the `INLINE_PATTERNS` block (around line 168, before `_ABSOLUTE_URL_RE`):

```python
ALERT_TYPES = {
    'NOTE':      ('ℹ️', 'blue_background'),
    'TIP':       ('💡', 'green_background'),
    'IMPORTANT': ('☝️', 'purple_background'),
    'WARNING':   ('⚠️', 'yellow_background'),
    'CAUTION':   ('🚨', 'red_background'),
}
ALERT_RE = re.compile(r'^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$')
```

- [ ] **Step 4: Add `create_callout_block` factory**

In `notion_writer.py`, add this function after `create_quote_block` (search for `def create_quote_block` and add immediately after its closing bracket):

```python
def create_callout_block(text: str, alert_type: str) -> Dict[str, Any]:
    """Create a callout block with icon + color from a GitHub alert type."""
    icon, color = ALERT_TYPES[alert_type]
    return {
        "type": "callout",
        "callout": {
            "rich_text": parse_rich_text(text),
            "icon": {"type": "emoji", "emoji": icon},
            "color": color,
        },
    }
```

- [ ] **Step 5: Add the callout detector branch in `_parse_lines`**

In `notion_writer.py`, locate the blockquote handler in `_parse_lines`:

```python
        # Blockquote
        elif line.startswith('>'):
            text = line[1:].strip()
            blocks.append(create_quote_block(text))
```

Replace it with:

```python
        # Blockquote — GitHub-alert callout takes priority over generic quote
        elif line.startswith('>'):
            first_body = line[1:].strip()
            alert_match = ALERT_RE.match(first_body)
            if alert_match:
                alert_type = alert_match.group(1)
                body_lines: List[str] = []
                i += 1
                while i < len(lines) and lines[i].lstrip().startswith('>'):
                    body_lines.append(lines[i].lstrip()[1:].lstrip())
                    i += 1
                body_text = '\n'.join(body_lines)
                blocks.append(create_callout_block(body_text, alert_type))
                continue
            blocks.append(create_quote_block(first_body))
```

- [ ] **Step 6: Run `test_callout_note` to verify it passes**

```bash
python3 test_parser.py 2>&1 | grep -A 6 "test_callout_note"
```

Expected: 6 ✓ checkmarks (all assertions pass).

- [ ] **Step 7: Add the remaining 4 alert-type tests**

In `test_parser.py`, add immediately after `test_callout_note`:

```python
def test_callout_tip():
    blocks = markdown_to_notion_blocks("> [!TIP]\n> Use this trick.")
    _assert(blocks[0]["type"] == "callout", "TIP block is callout")
    _assert(blocks[0]["callout"]["icon"]["emoji"] == "💡", "TIP icon is 💡")
    _assert(blocks[0]["callout"]["color"] == "green_background", "TIP color is green")


def test_callout_important():
    blocks = markdown_to_notion_blocks("> [!IMPORTANT]\n> Read this.")
    _assert(blocks[0]["callout"]["icon"]["emoji"] == "☝️", "IMPORTANT icon is ☝️")
    _assert(blocks[0]["callout"]["color"] == "purple_background", "IMPORTANT color is purple")


def test_callout_warning():
    blocks = markdown_to_notion_blocks("> [!WARNING]\n> Be careful.")
    _assert(blocks[0]["callout"]["icon"]["emoji"] == "⚠️", "WARNING icon is ⚠️")
    _assert(blocks[0]["callout"]["color"] == "yellow_background", "WARNING color is yellow")


def test_callout_caution():
    blocks = markdown_to_notion_blocks("> [!CAUTION]\n> Do not proceed.")
    _assert(blocks[0]["callout"]["icon"]["emoji"] == "🚨", "CAUTION icon is 🚨")
    _assert(blocks[0]["callout"]["color"] == "red_background", "CAUTION color is red")
```

Add all four to the `tests` list in `run_all()`:

```python
        test_callout_note,
        test_callout_tip,
        test_callout_important,
        test_callout_warning,
        test_callout_caution,
```

- [ ] **Step 8: Run the suite, expect all 5 callout tests to pass**

```bash
python3 test_parser.py 2>&1 | grep -E "^test_callout|^✅"
```

Expected: 5 callout function names listed, then `✅ All 21 tests passed` at the bottom.

- [ ] **Step 9: Add the unknown-alert fall-through test**

In `test_parser.py` after `test_callout_caution`:

```python
def test_callout_unknown_falls_through():
    """An unrecognized alert type renders as a plain quote with the marker preserved."""
    blocks = markdown_to_notion_blocks("> [!BOGUS]\n> some content")
    _assert(blocks[0]["type"] == "quote", "unknown alert renders as quote, not callout")
    quote_text = blocks[0]["quote"]["rich_text"][0]["text"]["content"]
    _assert("[!BOGUS]" in quote_text, "[!BOGUS] marker preserved in quote text")
```

Add to `tests` list right after `test_callout_caution`:

```python
        test_callout_caution,
        test_callout_unknown_falls_through,
```

- [ ] **Step 10: Run the suite, expect 22 tests passing**

```bash
python3 test_parser.py 2>&1 | tail -3
```

Expected: `✅ All 22 tests passed`

- [ ] **Step 11: Commit**

```bash
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py custom-skills/32-notion-writer/code/scripts/test_parser.py
git commit -m "$(cat <<'EOF'
feat(notion-writer): add GitHub-alert callout blocks

Adds support for > [!NOTE] / > [!TIP] / > [!IMPORTANT] / > [!WARNING] /
> [!CAUTION] callouts. Each alert type maps to a Notion callout block
with an emoji icon and matching colored background. Unknown alert types
(e.g. > [!BOGUS]) fall through to the existing quote handler with the
marker preserved.

+6 tests, 22 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Add `<details>` toggle blocks

**Files:**
- Modify: `custom-skills/32-notion-writer/code/scripts/notion_writer.py` (add factory, add detector)
- Modify: `custom-skills/32-notion-writer/code/scripts/test_parser.py` (add 4 tests)

HTML5 `<details>` with optional `<summary>` produces a Notion `toggle` block. The body recurses through `_parse_lines` so any block type (lists, code, nested toggles) works inside. Multi-line layout required: `<details>` and `</details>` on their own lines.

- [ ] **Step 1: Write the basic toggle test**

In `test_parser.py` after `test_callout_unknown_falls_through`:

```python
def test_toggle_basic():
    md = "<details>\n<summary>Click to expand</summary>\n\nInner paragraph.\n</details>"
    blocks = markdown_to_notion_blocks(md)
    _assert(len(blocks) == 1, "one block emitted")
    _assert(blocks[0]["type"] == "toggle", "block type is toggle")
    summary_text = blocks[0]["toggle"]["rich_text"][0]["text"]["content"]
    _assert(summary_text == "Click to expand", "summary text preserved")
    children = blocks[0]["toggle"]["children"]
    _assert(len(children) == 1, "one child block")
    _assert(children[0]["type"] == "paragraph", "child is paragraph")
```

Add to `tests` list right after `test_callout_unknown_falls_through`.

- [ ] **Step 2: Run the test to verify it fails**

```bash
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | grep -A 4 "test_toggle_basic"
```

Expected: FAIL — `<details>` is rendered as paragraph(s) currently.

- [ ] **Step 3: Add `create_toggle_block` factory**

In `notion_writer.py`, add after `create_callout_block`:

```python
def create_toggle_block(summary_text: str, children_blocks: List[Dict[str, Any]]) -> Dict[str, Any]:
    """Create a toggle block with summary rich-text and child blocks."""
    return {
        "type": "toggle",
        "toggle": {
            "rich_text": parse_rich_text(summary_text),
            "children": children_blocks,
        },
    }
```

- [ ] **Step 4: Add the `<details>` detector at the top of `_parse_lines`'s loop**

In `notion_writer.py`, locate the `_parse_lines` function. The existing first non-empty branch is the table check. Insert the toggle detector immediately AFTER the empty-line skip and BEFORE the table check:

Find this section:

```python
        # Skip empty lines
        if not line.strip():
            i += 1
            continue

        # Table (header row + separator + body rows)
        if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
```

Replace with:

```python
        # Skip empty lines
        if not line.strip():
            i += 1
            continue

        # Toggle (HTML5 <details>) — multi-line form, depth-tracked for nesting
        if line.strip() == '<details>':
            i += 1
            # Optional <summary>...</summary> on next non-blank line
            summary_text = ''
            while i < len(lines) and not lines[i].strip():
                i += 1
            if i < len(lines):
                summary_match = re.match(r'^\s*<summary>(.*)</summary>\s*$', lines[i])
                if summary_match:
                    summary_text = summary_match.group(1)
                    i += 1
            # Collect body lines until matching </details>, depth-tracked
            inner_lines: List[str] = []
            depth = 1
            while i < len(lines) and depth > 0:
                stripped_inner = lines[i].strip()
                if stripped_inner == '<details>':
                    depth += 1
                    inner_lines.append(lines[i])
                elif stripped_inner == '</details>':
                    depth -= 1
                    if depth > 0:
                        inner_lines.append(lines[i])
                else:
                    inner_lines.append(lines[i])
                i += 1
            if depth > 0:
                # Unclosed <details> at EOF — degrade to paragraphs
                import sys as _sys
                print("Warning: unclosed <details> at EOF; emitting body as paragraphs",
                      file=_sys.stderr)
                if summary_text:
                    blocks.append(create_paragraph_block(summary_text))
                for inner in inner_lines:
                    if inner.strip():
                        blocks.append(create_paragraph_block(inner))
                continue
            children = _parse_lines(inner_lines)
            blocks.append(create_toggle_block(summary_text, children))
            continue

        # Table (header row + separator + body rows)
        if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]):
```

- [ ] **Step 5: Run `test_toggle_basic` to verify it passes**

```bash
python3 test_parser.py 2>&1 | grep -A 5 "test_toggle_basic"
```

Expected: 5 ✓ checkmarks.

- [ ] **Step 6: Add the nested-blocks test**

In `test_parser.py` after `test_toggle_basic`:

```python
def test_toggle_nested_blocks():
    """Toggle body can hold any block type via _parse_lines recursion."""
    md = """<details>
<summary>Debug log</summary>

- step one
- step two

```python
print("hello")
```
</details>"""
    blocks = markdown_to_notion_blocks(md)
    _assert(blocks[0]["type"] == "toggle", "block is toggle")
    children = blocks[0]["toggle"]["children"]
    types = [c["type"] for c in children]
    _assert("bulleted_list_item" in types, "list child preserved")
    _assert("code" in types, "code child preserved")
    code_block = next(c for c in children if c["type"] == "code")
    _assert(code_block["code"]["language"] == "python", "code language preserved")
```

Add to `tests` list after `test_toggle_basic`.

- [ ] **Step 7: Run nested-blocks test**

```bash
python3 test_parser.py 2>&1 | grep -A 4 "test_toggle_nested_blocks"
```

Expected: 4 ✓ checkmarks.

- [ ] **Step 8: Add the nested-toggle test**

In `test_parser.py` after `test_toggle_nested_blocks`:

```python
def test_toggle_nested_toggle():
    """details_depth tracking allows <details> inside <details>."""
    md = """<details>
<summary>outer</summary>

<details>
<summary>inner</summary>

inner content
</details>
</details>"""
    blocks = markdown_to_notion_blocks(md)
    _assert(blocks[0]["type"] == "toggle", "outer block is toggle")
    outer_children = blocks[0]["toggle"]["children"]
    _assert(len(outer_children) == 1, "outer has exactly one child")
    _assert(outer_children[0]["type"] == "toggle", "outer child is also toggle")
    inner_summary = outer_children[0]["toggle"]["rich_text"][0]["text"]["content"]
    _assert(inner_summary == "inner", "inner summary preserved")
```

Add to `tests` list.

- [ ] **Step 9: Run nested-toggle test**

```bash
python3 test_parser.py 2>&1 | grep -A 5 "test_toggle_nested_toggle"
```

Expected: 5 ✓ checkmarks.

- [ ] **Step 10: Add the unclosed-toggle test**

In `test_parser.py` after `test_toggle_nested_toggle`:

```python
def test_toggle_unclosed_falls_through():
    """Missing </details> at EOF degrades to paragraphs, no crash."""
    md = "<details>\n<summary>oops</summary>\n\nbody line"
    blocks = markdown_to_notion_blocks(md)
    types = [b["type"] for b in blocks]
    _assert("toggle" not in types, "no toggle emitted on unclosed <details>")
    # Body and summary should appear as paragraphs
    paragraph_texts = [
        b["paragraph"]["rich_text"][0]["text"]["content"]
        for b in blocks if b["type"] == "paragraph"
    ]
    joined = " ".join(paragraph_texts)
    _assert("oops" in joined, "summary preserved as paragraph")
    _assert("body line" in joined, "body preserved as paragraph")
```

Add to `tests` list.

- [ ] **Step 11: Run the full suite, expect 26 tests passing**

```bash
python3 test_parser.py 2>&1 | tail -3
```

Expected: `✅ All 26 tests passed`

- [ ] **Step 12: Commit**

```bash
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py custom-skills/32-notion-writer/code/scripts/test_parser.py
git commit -m "$(cat <<'EOF'
feat(notion-writer): add toggle blocks via HTML5 <details>

Multi-line <details>...</details> with optional <summary> emits a Notion
toggle block. Body recurses through _parse_lines so any block type
(lists, code, nested toggles) is supported inside. Depth tracking lets
<details> nest inside <details>. Unclosed <details> at EOF degrades to
plain paragraphs with a stderr warning instead of crashing.

+4 tests, 26 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Add `::: columns` blocks

**Files:**
- Modify: `custom-skills/32-notion-writer/code/scripts/notion_writer.py` (add factory + detector)
- Modify: `custom-skills/32-notion-writer/code/scripts/test_parser.py` (add 3 tests)

Pandoc fenced div `::: columns` opens a Notion `column_list`. Each child column is delimited by `::: column` and `:::`. Single-column blocks degrade to paragraphs (Notion requires ≥2 columns). A single depth counter tracks both nested `::: columns` and column opens, decremented on every `:::`.

- [ ] **Step 1: Write the two-column test**

In `test_parser.py` after `test_toggle_unclosed_falls_through`:

```python
def test_columns_two():
    md = """::: columns
::: column
left content
:::
::: column
right content
:::
:::"""
    blocks = markdown_to_notion_blocks(md)
    _assert(len(blocks) == 1, "one block emitted")
    _assert(blocks[0]["type"] == "column_list", "block type is column_list")
    cols = blocks[0]["column_list"]["children"]
    _assert(len(cols) == 2, "two columns")
    _assert(cols[0]["type"] == "column", "child 0 is column")
    _assert(cols[1]["type"] == "column", "child 1 is column")
    left_para = cols[0]["column"]["children"][0]["paragraph"]["rich_text"][0]["text"]["content"]
    right_para = cols[1]["column"]["children"][0]["paragraph"]["rich_text"][0]["text"]["content"]
    _assert(left_para == "left content", "left column content preserved")
    _assert(right_para == "right content", "right column content preserved")
```

Add to `tests` list.

- [ ] **Step 2: Run test to verify it fails**

```bash
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | grep -A 6 "test_columns_two"
```

Expected: FAIL — `::: columns` lines render as paragraphs currently.

- [ ] **Step 3: Add `create_column_list_block` factory**

In `notion_writer.py`, add after `create_toggle_block`:

```python
def create_column_list_block(columns: List[List[Dict[str, Any]]]) -> Dict[str, Any]:
    """Create a column_list block; each item in `columns` is the children-list for one column."""
    return {
        "type": "column_list",
        "column_list": {
            "children": [
                {"type": "column", "column": {"children": col_blocks}}
                for col_blocks in columns
            ],
        },
    }
```

- [ ] **Step 4: Add the `::: columns` detector**

In `notion_writer.py`, locate the toggle detector inside `_parse_lines` (the `if line.strip() == '<details>':` branch added in Task 3). Insert the columns detector immediately AFTER the toggle detector's closing `continue` and BEFORE the table check.

The detector uses a single depth counter that increments on either `::: columns` (nested wrapper) or `::: column` (column open) and decrements on `:::` (closes innermost). When depth returns to 0, our wrapper is closed.

Find:

```python
            children = _parse_lines(inner_lines)
            blocks.append(create_toggle_block(summary_text, children))
            continue

        # Table (header row + separator + body rows)
```

Replace with:

```python
            children = _parse_lines(inner_lines)
            blocks.append(create_toggle_block(summary_text, children))
            continue

        # Columns (Pandoc fenced div ::: columns)
        if line.strip() == '::: columns':
            columns_lines: List[List[str]] = []
            current_col: Optional[List[str]] = None
            i += 1
            depth = 1  # we are inside our own wrapper
            while i < len(lines) and depth > 0:
                stripped_col = lines[i].strip()
                if stripped_col == '::: columns':
                    depth += 1
                    if current_col is not None:
                        current_col.append(lines[i])
                    i += 1
                    continue
                if stripped_col == '::: column':
                    depth += 1
                    if depth == 2:
                        # Top-level column inside our wrapper
                        if current_col is not None:
                            columns_lines.append(current_col)
                        current_col = []
                    else:
                        # Column inside a nested wrapper — record verbatim
                        if current_col is not None:
                            current_col.append(lines[i])
                    i += 1
                    continue
                if stripped_col == ':::':
                    depth -= 1
                    if depth == 0:
                        # Closing our wrapper
                        if current_col is not None:
                            columns_lines.append(current_col)
                            current_col = None
                        i += 1
                        break
                    if depth == 1:
                        # Closing a top-level column
                        if current_col is not None:
                            columns_lines.append(current_col)
                            current_col = None
                    else:
                        # Closing something nested — record verbatim
                        if current_col is not None:
                            current_col.append(lines[i])
                    i += 1
                    continue
                # Regular content line — append to current column if open
                if current_col is not None:
                    current_col.append(lines[i])
                i += 1
            if depth > 0:
                # Unclosed wrapper at EOF — degrade to paragraphs
                import sys as _sys
                print("Warning: unclosed ::: columns at EOF; emitting as paragraphs",
                      file=_sys.stderr)
                if current_col is not None:
                    columns_lines.append(current_col)
                for col in columns_lines:
                    for inner in col:
                        if inner.strip():
                            blocks.append(create_paragraph_block(inner))
                continue
            # Drop columns that are pure whitespace
            columns_lines = [c for c in columns_lines if any(li.strip() for li in c)]
            if len(columns_lines) < 2:
                # Notion requires >= 2 columns; single column degrades to paragraphs
                for col in columns_lines:
                    for inner in col:
                        if inner.strip():
                            blocks.append(create_paragraph_block(inner))
                continue
            column_blocks = [_parse_lines(col_lines) for col_lines in columns_lines]
            blocks.append(create_column_list_block(column_blocks))
            continue

        # Table (header row + separator + body rows)
```

- [ ] **Step 5: Run `test_columns_two` to verify it passes**

```bash
python3 test_parser.py 2>&1 | grep -A 7 "test_columns_two"
```

Expected: 7 ✓ checkmarks.

- [ ] **Step 6: Add the columns-with-blocks test**

In `test_parser.py` after `test_columns_two`:

```python
def test_columns_with_blocks():
    """Columns can hold any block type via per-column _parse_lines recursion."""
    md = """::: columns
::: column
- bullet a
- bullet b
:::
::: column
```python
print(1)
```
:::
:::"""
    blocks = markdown_to_notion_blocks(md)
    _assert(blocks[0]["type"] == "column_list", "outer is column_list")
    cols = blocks[0]["column_list"]["children"]
    _assert(len(cols) == 2, "two columns")
    left_types = [c["type"] for c in cols[0]["column"]["children"]]
    right_types = [c["type"] for c in cols[1]["column"]["children"]]
    _assert(left_types.count("bulleted_list_item") == 2, "left column has 2 bullets")
    _assert("code" in right_types, "right column has code block")
```

Add to `tests` list.

- [ ] **Step 7: Add the single-column degrade test**

In `test_parser.py` after `test_columns_with_blocks`:

```python
def test_columns_single_degrades():
    """Single-column ::: columns block degrades to paragraphs (Notion requires >= 2)."""
    md = """::: columns
::: column
only one column here
:::
:::"""
    blocks = markdown_to_notion_blocks(md)
    types = [b["type"] for b in blocks]
    _assert("column_list" not in types, "no column_list emitted for single column")
    paragraph_texts = [
        b["paragraph"]["rich_text"][0]["text"]["content"]
        for b in blocks if b["type"] == "paragraph"
    ]
    joined = " ".join(paragraph_texts)
    _assert("only one column here" in joined, "content preserved as paragraph")
```

Add both new tests to the `tests` list right after `test_columns_two`:

```python
        test_columns_two,
        test_columns_with_blocks,
        test_columns_single_degrades,
```

- [ ] **Step 8: Run the full suite, expect 29 tests passing**

```bash
python3 test_parser.py 2>&1 | tail -3
```

Expected: `✅ All 29 tests passed`

- [ ] **Step 9: Commit**

```bash
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py custom-skills/32-notion-writer/code/scripts/test_parser.py
git commit -m "$(cat <<'EOF'
feat(notion-writer): add column_list blocks via Pandoc fenced div

Pandoc-style ::: columns / ::: column / ::: blocks emit a Notion
column_list with column children. Each column's body recurses through
_parse_lines so any block type (lists, code, nested columns) works
inside. Single-column wrappers degrade to plain paragraphs because
Notion requires at least two columns.

+3 tests, 29 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Add inline page mentions

**Files:**
- Modify: `custom-skills/32-notion-writer/code/scripts/notion_writer.py` (add INLINE pattern + emit branch)
- Modify: `custom-skills/32-notion-writer/code/scripts/test_parser.py` (add 3 tests)

Page mentions are inline rich-text. Pattern `@[Title](id-or-url)` extends `INLINE_PATTERNS`. Valid IDs (raw 32-hex, dashed UUID, or Notion URL) emit a `mention` rich-text span; invalid input degrades to plain text `@Title`.

- [ ] **Step 1: Write the ID-mention test**

In `test_parser.py` after `test_columns_single_degrades`:

```python
def test_mention_id():
    """@[Title](32-hex-id) produces a mention rich-text span with page reference."""
    raw_id = "abcdef0123456789abcdef0123456789"
    spans = parse_rich_text(f"see @[Roadmap]({raw_id}) for plans")
    mention_spans = [s for s in spans if s.get("type") == "mention"]
    _assert(len(mention_spans) == 1, "one mention span emitted")
    m = mention_spans[0]
    _assert(m["mention"]["type"] == "page", "mention type is page")
    _assert(m["mention"]["page"]["id"].replace("-", "") == raw_id, "page id resolved")
    _assert(m.get("plain_text") == "Roadmap", "plain_text is the title")
```

Add to `tests` list.

- [ ] **Step 2: Run to verify it fails**

```bash
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | grep -A 4 "test_mention_id"
```

Expected: FAIL — no mention spans emitted (currently `@[...](...)` is not parsed as anything special).

- [ ] **Step 3: Add the `mention` pattern to `INLINE_PATTERNS`**

In `notion_writer.py`, find:

```python
INLINE_PATTERNS = [
    ('code',          re.compile(r'`([^`\n]+)`')),
    ('link',          re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')),
```

Insert the `mention` entry BEFORE the `link` entry so it takes priority:

```python
INLINE_PATTERNS = [
    ('code',          re.compile(r'`([^`\n]+)`')),
    ('mention',       re.compile(r'@\[([^\]]+)\]\(([^)\s]+)\)')),
    ('link',          re.compile(r'\[([^\]]+)\]\(([^)\s]+)\)')),
```

- [ ] **Step 4: Add the `mention` emit branch in `parse_rich_text`**

In `notion_writer.py`, locate `parse_rich_text`. Find:

```python
        if kind == 'code':
            spans.append(_rich_span(m.group(1), code=True))
        elif kind == 'link':
            link_text, link_url = m.group(1), m.group(2)
            if _is_absolute_url(link_url):
```

Insert the mention branch BEFORE the link branch:

```python
        if kind == 'code':
            spans.append(_rich_span(m.group(1), code=True))
        elif kind == 'mention':
            mention_title, mention_target = m.group(1), m.group(2)
            page_id = extract_notion_id(mention_target)
            if page_id:
                spans.append({
                    "type": "mention",
                    "mention": {
                        "type": "page",
                        "page": {"id": format_id_with_dashes(page_id)},
                    },
                    "plain_text": mention_title,
                })
            else:
                # Invalid ID — degrade to plain text "@Title"
                spans.append(_rich_span(f"@{mention_title}"))
        elif kind == 'link':
            link_text, link_url = m.group(1), m.group(2)
            if _is_absolute_url(link_url):
```

- [ ] **Step 5: Run `test_mention_id` to verify it passes**

```bash
python3 test_parser.py 2>&1 | grep -A 4 "test_mention_id"
```

Expected: 4 ✓ checkmarks.

- [ ] **Step 6: Add the URL-mention test**

In `test_parser.py` after `test_mention_id`:

```python
def test_mention_url():
    """@[Title](https://notion.so/Page-Title-id) extracts ID from URL."""
    raw_id = "abcdef0123456789abcdef0123456789"
    url = f"https://notion.so/My-Page-{raw_id}"
    spans = parse_rich_text(f"check @[My Page]({url})")
    mention_spans = [s for s in spans if s.get("type") == "mention"]
    _assert(len(mention_spans) == 1, "one mention span emitted from URL form")
    _assert(mention_spans[0]["mention"]["page"]["id"].replace("-", "") == raw_id,
            "ID extracted from Notion URL")
```

Add to `tests` list.

- [ ] **Step 7: Run URL-mention test**

```bash
python3 test_parser.py 2>&1 | grep -A 3 "test_mention_url"
```

Expected: 2 ✓ checkmarks.

- [ ] **Step 8: Add the invalid-mention fall-back test**

In `test_parser.py` after `test_mention_url`:

```python
def test_mention_invalid_falls_back():
    """A non-resolvable target degrades to plain text '@Title' with no link."""
    spans = parse_rich_text("ping @[Bob](not-a-real-id) please")
    mention_spans = [s for s in spans if s.get("type") == "mention"]
    _assert(len(mention_spans) == 0, "no mention span for invalid id")
    plain_text_joined = "".join(
        s["text"]["content"] for s in spans if s.get("type") == "text"
    )
    _assert("@Bob" in plain_text_joined, "title rendered as plain '@Bob'")
    _assert("not-a-real-id" not in plain_text_joined, "invalid id stripped from output")
```

Add all three mention tests to the `tests` list:

```python
        test_columns_single_degrades,
        test_mention_id,
        test_mention_url,
        test_mention_invalid_falls_back,
```

- [ ] **Step 9: Run the full suite, expect 32 tests passing**

```bash
python3 test_parser.py 2>&1 | tail -3
```

Expected: `✅ All 32 tests passed`

- [ ] **Step 10: Commit**

```bash
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/scripts/notion_writer.py custom-skills/32-notion-writer/code/scripts/test_parser.py
git commit -m "$(cat <<'EOF'
feat(notion-writer): add inline page mentions via @[Title](id-or-url)

Inline rich-text gets a new 'mention' pattern matching @[Title](target).
Target can be a raw 32-hex page ID, a dashed UUID, or a Notion URL —
all resolved via the existing extract_notion_id helper. Invalid targets
degrade to plain text '@Title' with no warning (avoids false-positive
spam from unrelated @[x](y) patterns).

+3 tests, 32 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Update CLAUDE.md docs and version bump

**Files:**
- Modify: `custom-skills/32-notion-writer/code/CLAUDE.md` (markdown table additions, examples, version footer)

Document the four new block types and bump the version from 1.1.0 → 1.2.0 with a changelog entry.

- [ ] **Step 1: Add new rows to the supported-elements table**

Open `custom-skills/32-notion-writer/code/CLAUDE.md` and find the "Supported Elements" table:

```markdown
### Supported Elements

| Markdown | Notion Block |
|----------|--------------|
| `# Heading` | Heading 1 |
| `## Heading` | Heading 2 |
| `### Heading` | Heading 3 |
| `- item` | Bulleted list |
| `1. item` | Numbered list |
| `- [ ] task` | To-do (unchecked) |
| `- [x] task` | To-do (checked) |
| `> quote` | Quote |
| `` ```code``` `` | Code block |
| `---` | Divider |
| Paragraphs | Paragraph |
```

Replace with:

```markdown
### Supported Elements

| Markdown | Notion Block |
|----------|--------------|
| `# Heading` | Heading 1 |
| `## Heading` | Heading 2 |
| `### Heading` | Heading 3 |
| `- item` | Bulleted list |
| `1. item` | Numbered list |
| `- [ ] task` | To-do (unchecked) |
| `- [x] task` | To-do (checked) |
| `> quote` | Quote |
| `> [!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` / `[!CAUTION]` | Callout (with icon + colored background) |
| `<details><summary>X</summary> ... </details>` | Toggle (multi-line form, recursive children) |
| `::: columns / ::: column / :::` | Column list (Pandoc fenced div, ≥2 columns required) |
| `` ```code``` `` | Code block |
| `---` | Divider |
| Tables (`\| col \|`) | Table |
| Paragraphs | Paragraph |
```

- [ ] **Step 2: Add new rows to the inline rich-text table**

In the same file, find the "Inline rich-text" table (added in Phase 2):

```markdown
| Markdown | Result |
|----------|--------|
| `**bold**` or `__bold__` | bold |
| `*italic*` or `_italic_` | italic |
| `` `code` `` | inline code |
| `~~strike~~` | strikethrough |
| `[text](https://...)` | link (absolute URLs only) |
| `[text](#anchor)` | bold (Notion rejects fragment URLs) |
| `[text](relative/path.md)` | plain text (Notion rejects relative URLs) |
```

Replace with:

```markdown
| Markdown | Result |
|----------|--------|
| `**bold**` or `__bold__` | bold |
| `*italic*` or `_italic_` | italic |
| `` `code` `` | inline code |
| `~~strike~~` | strikethrough |
| `[text](https://...)` | link (absolute URLs only) |
| `[text](#anchor)` | bold (Notion rejects fragment URLs) |
| `[text](relative/path.md)` | plain text (Notion rejects relative URLs) |
| `@[Title](page-id-or-notion-url)` | page mention (resolves via Notion URL or raw 32-hex ID) |
```

- [ ] **Step 3: Add new examples in the Examples section**

In the same file, find the "Pipe from Another Tool" example. Insert these new examples BEFORE it:

```markdown
### Callouts (GitHub alerts)

```markdown
> [!NOTE]
> Just FYI: this method is idempotent.

> [!WARNING]
> Don't run this in production without a backup.
```

Renders as Notion callout blocks with corresponding emoji icon and colored background.

### Toggles (HTML5 `<details>`)

```markdown
<details>
<summary>Click to expand: full debug log</summary>

```bash
$ python notion_writer.py --test
✅ Connected
```

Lists, code blocks, and even nested `<details>` work inside.
</details>
```

Multi-line form required: `<details>` and `</details>` must be on their own lines.

### Columns (Pandoc fenced div)

```markdown
::: columns
::: column
**Column 1**

- item a
- item b
:::
::: column
**Column 2**

```python
print("hello")
```
:::
:::
```

≥2 columns required by Notion. Single-column blocks degrade to plain paragraphs.

### Page mentions

```markdown
See @[Architecture Decision Record](https://notion.so/ADR-abcdef0123456789abcdef0123456789) for context.
```

Both Notion URLs and raw 32-hex IDs work. Invalid targets fall back to plain text `@Title`.

```

- [ ] **Step 4: Bump version footer**

In the same file, find the version footer:

```markdown
*Version 1.1.0 | Claude Code | 2026-04-27*

Changelog:
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
- 1.0.0 — Initial release with markdown→Notion block conversion.
```

Replace with:

```markdown
*Version 1.2.0 | Claude Code | 2026-04-27*

Changelog:
- 1.2.0 — Extended block coverage: GitHub-alert callouts, HTML5 `<details>` toggles, Pandoc `::: columns` fenced div, inline `@[Title](id-or-url)` page mentions. Parser made reentrant to support full recursion inside container blocks.
- 1.1.0 — Migrated to Notion API 2025-09-03 (multi-source databases). Added `--properties` JSON flag, `--upsert-by` for idempotency, anchor-link parser fix, friendlier API error messages.
- 1.0.0 — Initial release with markdown→Notion block conversion.
```

- [ ] **Step 5: Verify the docs file is well-formed**

```bash
cd /Users/ourdigital/Project/our-claude-skills
python3 -c "
content = open('custom-skills/32-notion-writer/code/CLAUDE.md').read()
assert '> [!NOTE]' in content, 'callout doc added'
assert '<details>' in content, 'toggle doc added'
assert '::: columns' in content, 'columns doc added'
assert '@[Title](page-id-or-notion-url)' in content, 'mention doc added'
assert '1.2.0' in content, 'version bumped'
print('CLAUDE.md updated correctly')
"
```

Expected: `CLAUDE.md updated correctly`

- [ ] **Step 6: Run the full test suite one last time**

```bash
cd /Users/ourdigital/Project/our-claude-skills/custom-skills/32-notion-writer/code/scripts
python3 test_parser.py 2>&1 | tail -3
```

Expected: `✅ All 32 tests passed`

- [ ] **Step 7: Commit**

```bash
cd /Users/ourdigital/Project/our-claude-skills
git add custom-skills/32-notion-writer/code/CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(notion-writer): document Phase 3c block coverage + bump to v1.2.0

Adds rows for callouts, toggles, columns, and page mentions in the
supported-elements and inline rich-text tables. Adds usage examples
for each. Updates version footer with changelog entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Final verification

After all six tasks are complete:

- [ ] **Run the full test suite**: 32 tests passing
- [ ] **Sanity-check imports**: `python3 -c "from notion_writer import markdown_to_notion_blocks, create_callout_block, create_toggle_block, create_column_list_block; print('all factories importable')"`
- [ ] **Confirm git log shows 6 new commits** since `c66b5e1` (the spec commit), one per task

---

## Out-of-scope follow-ups (for Phase 3b/3a)

- **Round-trip tests** (markdown → blocks → markdown) — deferred to Phase 3b when the reverse converter exists
- **Image upload, bookmark, embed, equation, synced block** — added when concrete use cases demand them
- **Single-line `<details><summary>X</summary>body</details>`** — not supported in v1; multi-line form required
