Skip to main content

Plan: Migrate @ai-metadata to Obsidian YAML Frontmatter

Objective

Replace the custom `` HTML comment format with standard Obsidian YAML frontmatter. This unlocks the full Obsidian feature set: properties panel, graph view, Dataview queries, backlinks, and tag browsing -- all invisible with the current format.

This file itself uses the target format as a live example.

Current State

FormatCount
@ai-metadata only151
Both (partial migration)6
Neither9
YAML frontmatter only (correct)6
Total172

Field Analysis: What Moves Where

All @ai-metadata fields were audited. Most are just as useful to humans as to AI -- the only reason they were invisible was the HTML comment format. Moving them to YAML frontmatter makes them visible in Obsidian without losing any functionality.

Fields → YAML frontmatter (visible in Obsidian properties)

@ai-metadata fieldYAML fieldWhy it's useful in Obsidian
keywordstags: []Graph tag clustering, filter panel, tag browsing
relatedrelates-to: []Becomes real wikilinks -- the primary graph connections
summarydescription:Shows in hover preview and properties panel; Dataview-queryable
last-verifiedupdated:Doc freshness visible at a glance
prioritypriority:Filter by priority in Dataview
sourcesource:Track which bot or human authored the doc
(inferred)type:Browse by doc type in Dataview
(default: active)status:Filter active vs archived docs
(from git)date:Original creation date

Fields to drop

@ai-metadata fieldReason
titleRedundant -- Obsidian uses H1 heading. Shown in properties if present but adds noise.

Fields to keep hidden (Notion sync only -- 31 files)

@ai-metadata fieldReason to keep hidden
notion-page-idInfrastructure for scripts/notion-sync/ -- sync script reads this
notion-last-editedSame
synced-atSame

Important: The 31 Notion-synced files (in docs/product/notion-synced/) must NOT have their Notion fields removed. The scripts/notion-sync/ TypeScript code writes and reads these fields. Handle them separately -- keep the `` block for Notion fields only, alongside the new YAML frontmatter.

Updating the Notion sync script to use YAML instead is a separate task after this migration.

Target Schema

---
date: YYYY-MM-DD # creation date (from git or last-verified)
updated: YYYY-MM-DD # optional, from last-verified
type: architecture | plan | runbook | research | reference | analysis | adr | integration | product
status: draft | active | approved | archived | deprecated
tags: [kebab-case, comma-separated]
source: human | sv0-echo | sv0-delta | sv0-blue | sv0-charlie | owen
priority: high | medium | low # optional
description: "One sentence summary of this document."
relates-to: # optional -- the graph connections
- "[[document-name]]"
- "[[another-document]]"
---

The relates-to field powers the graph

This is the most important conversion. The current related field uses file paths:

related: 04-oaa-mapping-synthesis.md, 01-data-model.md, ../../product/wedges/w1-exposure/definition.md

It must become Obsidian wikilinks:

relates-to:
- "[[04-oaa-mapping-synthesis]]"
- "[[01-data-model]]"
- "[[definition]]"

Obsidian resolves these by filename (shortest unique match). Remove the path and extension. The graph view will show edges between connected documents automatically.

The description field enables hover previews

In Obsidian, hovering over a [[wikilink]] shows the first paragraph or description property. With 156 rich summaries already written, moving them to description: gives every document a meaningful hover preview without any new writing.

Type Inference Rules (from directory path)

Path patterntype
architecture/decisions/adr
architecture/research/research
architecture/architecture
analysis/analysis
product/market-research/research
product/strategy/product
product/wedges/product
product/notion-synced/product
product/product
integrations/integration
runbooks/runbook
plans/plan
api/reference
(default)reference

Migration Script

Create scripts/migrate_metadata.py, run it, then delete it. One-time tool.

#!/usr/bin/env python3
"""
migrate_metadata.py: Convert @ai-metadata HTML comments to Obsidian YAML frontmatter.
Handles Notion-synced files specially (keeps @ai-metadata for notion fields).
Usage: python3 scripts/migrate_metadata.py docs/
"""
import re
import sys
from pathlib import Path
from datetime import date

TYPE_MAP = {
'architecture/decisions': 'adr',
'architecture/research': 'research',
'architecture': 'architecture',
'analysis': 'analysis',
'product/market-research': 'research',
'product/strategy': 'product',
'product/wedges': 'product',
'product/notion-synced': 'product',
'product': 'product',
'integrations': 'integration',
'runbooks': 'runbook',
'plans': 'plan',
'api': 'reference',
}

# Notion sync fields -- keep in @ai-metadata, do NOT move to YAML
NOTION_FIELDS = {'notion-page-id', 'notion-last-edited', 'synced-at'}

def infer_type(path: Path, docs_root: Path) -> str:
rel = str(path.relative_to(docs_root))
for pattern, doc_type in TYPE_MAP.items():
if rel.startswith(pattern):
return doc_type
return 'reference'

def parse_ai_metadata(content: str) -> dict:
match = re.search(r'<!--\s*@ai-metadata\s*(.*?)-->', content, re.DOTALL)
if not match:
return {}
meta = {}
for line in match.group(1).strip().splitlines():
if ':' in line:
key, _, val = line.partition(':')
meta[key.strip()] = val.strip().strip('"')
return meta

def keywords_to_tags(keywords: str) -> list:
return [k.strip().lower().replace(' ', '-') for k in keywords.split(',') if k.strip()]

def related_to_wikilinks(related: str) -> list:
links = []
for path in related.split(','):
path = path.strip()
if not path:
continue
# Take just the filename without extension
name = Path(path).stem
links.append(f'"[[{name}]]"')
return links

def build_frontmatter(meta: dict, doc_type: str) -> str:
tags = keywords_to_tags(meta.get('keywords', ''))
date_val = meta.get('last-verified', str(date.today()))
updated = meta.get('last-verified', '')
priority = meta.get('priority', '')
description = meta.get('summary', '')
related = meta.get('related', '')
source = meta.get('source', 'human')

lines = ['---', f'date: {date_val}']
if updated and updated != date_val:
lines.append(f'updated: {updated}')
lines += [f'type: {doc_type}', 'status: active', f'source: {source}']
if tags:
lines.append(f'tags: [{", ".join(tags)}]')
if priority:
lines.append(f'priority: {priority}')
if description:
# Truncate very long summaries -- keep first sentence only
first_sentence = description.split('. ')[0].rstrip('.')
if len(first_sentence) > 200:
first_sentence = first_sentence[:197] + '...'
lines.append(f'description: "{first_sentence}"')
if related:
wikilinks = related_to_wikilinks(related)
if wikilinks:
lines.append('relates-to:')
for link in wikilinks:
lines.append(f' - {link}')
lines.append('---')
return '\n'.join(lines)

def rebuild_notion_metadata(meta: dict) -> str:
"""Rebuild @ai-metadata block with only Notion-specific fields."""
notion_lines = []
for field in NOTION_FIELDS:
if field in meta:
notion_lines.append(f'{field}: {meta[field]}')
if not notion_lines:
return ''
return '<!--\n@ai-metadata\n' + '\n'.join(notion_lines) + '\n-->\n'

def migrate_file(path: Path, docs_root: Path) -> bool:
content = path.read_text(encoding='utf-8')
has_yaml = content.startswith('---')
has_ai_meta = '@ai-metadata' in content
if has_yaml and not has_ai_meta:
return False # already correct, skip

doc_type = infer_type(path, docs_root)
meta = parse_ai_metadata(content)
is_notion_file = any(f in meta for f in NOTION_FIELDS)

# Remove the full @ai-metadata block
content = re.sub(r'<!--\s*@ai-metadata\s*.*?-->\n?', '', content, flags=re.DOTALL)
content = content.lstrip('\n')

# Remove existing YAML frontmatter if present (rebuild cleanly)
if content.startswith('---'):
end = content.find('---', 3)
if end != -1:
content = content[end + 3:].lstrip('\n')

frontmatter = build_frontmatter(meta, doc_type)

# For Notion-synced files: append a slim @ai-metadata block with only Notion fields
if is_notion_file:
notion_block = rebuild_notion_metadata(meta)
path.write_text(frontmatter + '\n\n' + notion_block + '\n' + content, encoding='utf-8')
else:
path.write_text(frontmatter + '\n\n' + content, encoding='utf-8')
return True

if __name__ == '__main__':
docs_root = Path(sys.argv[1])
changed = 0
notion_count = 0
for md_file in sorted(docs_root.rglob('*.md')):
if migrate_file(md_file, docs_root):
is_notion = 'notion-synced' in str(md_file)
label = ' [notion]' if is_notion else ''
print(f' migrated{label}: {md_file.relative_to(docs_root)}')
changed += 1
if is_notion:
notion_count += 1
print(f'\nDone. {changed} files updated ({notion_count} Notion-synced, kept slim @ai-metadata).')

Steps for the Bot Executing This Plan

  1. Create the script

    # Create scripts/migrate_metadata.py with content from above
  2. Dry run on a sample -- review before full run

    # Test on one regular file and one Notion-synced file
    cp docs/architecture/decisions/adr-010-workload-entity-rename.md /tmp/adr-010.md.bak
    cp docs/product/notion-synced/actionability-and-remediation-guidance-w1.md /tmp/notion.md.bak
    python3 scripts/migrate_metadata.py docs/architecture/decisions/
    # Check: YAML has tags, description, relates-to with [[wikilinks]]
    # Check: no @ai-metadata block remains
    git checkout docs/architecture/decisions/
    python3 scripts/migrate_metadata.py docs/product/notion-synced/
    # Check: YAML frontmatter present
    # Check: slim @ai-metadata block kept with only notion-page-id, synced-at, notion-last-edited
    git checkout docs/product/notion-synced/
  3. Full run

    python3 scripts/migrate_metadata.py docs/
  4. Verify

    # Regular files: 0 @ai-metadata blocks (except Notion files)
    grep -rl "@ai-metadata" docs/ | grep -v notion-synced | wc -l # expect 0
    # All files have YAML frontmatter
    find docs/ -name "*.md" | while read f; do head -1 "$f" | grep -q "^---" || echo "MISSING: $f"; done
    # Build passes
    mkdocs build --strict
  5. Commit

    docs: migrate @ai-metadata to Obsidian YAML frontmatter (172 files)

    - summary → description (hover previews in Obsidian)
    - keywords → tags (graph tag clustering)
    - related → relates-to with [[wikilinks]] (graph edges)
    - Notion-synced files: keep slim @ai-metadata for notion-page-id/synced-at
  6. Delete the script in a follow-up commit

  7. Open PR -- Blue reviews before merge

What This Enables in Obsidian

After migration, you can use these Dataview queries:

TABLE description, date, source
FROM "docs/architecture/decisions"
WHERE type = "adr"
SORT date DESC
TABLE description, tags
FROM "docs/product"
WHERE status = "active" AND contains(tags, "wedge1")
SORT date DESC
TABLE relates-to, date
FROM "docs"
WHERE relates-to
SORT date DESC

And the graph view will show connections between every document that has relates-to links.

Success Criteria

  • grep -rl "@ai-metadata" docs/ | grep -v notion-synced | wc -l returns 0
  • mkdocs build --strict passes
  • Obsidian properties panel shows description, tags, relates-to on all files
  • Graph view shows edges between connected documents
  • Dataview queries above return results