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
| Format | Count |
|---|---|
@ai-metadata only | 151 |
| Both (partial migration) | 6 |
| Neither | 9 |
| YAML frontmatter only (correct) | 6 |
| Total | 172 |
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 field | YAML field | Why it's useful in Obsidian |
|---|---|---|
keywords | tags: [] | Graph tag clustering, filter panel, tag browsing |
related | relates-to: [] | Becomes real wikilinks -- the primary graph connections |
summary | description: | Shows in hover preview and properties panel; Dataview-queryable |
last-verified | updated: | Doc freshness visible at a glance |
priority | priority: | Filter by priority in Dataview |
source | source: | 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 field | Reason |
|---|---|
title | Redundant -- Obsidian uses H1 heading. Shown in properties if present but adds noise. |
Fields to keep hidden (Notion sync only -- 31 files)
| @ai-metadata field | Reason to keep hidden |
|---|---|
notion-page-id | Infrastructure for scripts/notion-sync/ -- sync script reads this |
notion-last-edited | Same |
synced-at | Same |
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 pattern | type |
|---|---|
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
-
Create the script
# Create scripts/migrate_metadata.py with content from above -
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/ -
Full run
python3 scripts/migrate_metadata.py docs/ -
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 -
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 -
Delete the script in a follow-up commit
-
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 -lreturns 0mkdocs build --strictpasses- Obsidian properties panel shows
description,tags,relates-toon all files - Graph view shows edges between connected documents
- Dataview queries above return results