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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 132 additions & 8 deletions ai-agents/skills/project-brain/scripts/convert-to-okf.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,113 @@ param(

$ErrorActionPreference = 'Stop'

function Get-WikilinkIndex {
param([string] $RepoRoot)

# A bare-filename wikilink target (no '/' in it, Obsidian-style same-vault resolution)
# carries no directory information — resolving it as if it were already bundle-root-
# relative (the old behavior) silently produces a wrong link whenever the target file
# actually lives in a subdirectory. Build a basename -> real-path index once per repo so
# such targets resolve to where the file genuinely is.
if (-not $script:WikilinkIndexCache) { $script:WikilinkIndexCache = @{} }
if ($script:WikilinkIndexCache.ContainsKey($RepoRoot)) {
return $script:WikilinkIndexCache[$RepoRoot]
}

$index = @{}
# No -ErrorAction SilentlyContinue: a swallowed enumeration failure here would silently
# cache an incomplete index, degrading every subsequent lookup to a wrong or dead link
# with no signal of why — let it terminate ($ErrorActionPreference = 'Stop' above) so a
# real traversal failure surfaces instead of masquerading as "no such file".
$mdFiles = Get-ChildItem -LiteralPath $RepoRoot -Filter '*.md' -File -Recurse |
Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' }
foreach ($f in $mdFiles) {
$rel = ([IO.Path]::GetRelativePath($RepoRoot, $f.FullName)) -replace '\\', '/'
$base = ([IO.Path]::GetFileNameWithoutExtension($f.Name)).ToLowerInvariant()
if (-not $index.ContainsKey($base)) {
$index[$base] = [System.Collections.Generic.List[string]]::new()
}
$index[$base].Add($rel)
}
$script:WikilinkIndexCache[$RepoRoot] = $index
return $index
}

function Resolve-AmbiguousMatch {
param([string[]] $Found, [string] $Target, [string] $CurrentDir)

# Wrap in @() — Where-Object unwraps a single match to a bare string rather than a
# 1-element array, and indexing a string with [0] returns its first character, not
# the string itself (silently resolving to a garbage one-letter path).
$sameDir = @($Found | Where-Object {
$dir = if ($_ -match '^(.*)/[^/]+$') { $Matches[1] } else { '' }
$dir -eq $CurrentDir
})
if ($sameDir.Count -gt 0) {
Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($Found.Count) matches) — resolved to same-directory match '$($sameDir[0])'."
return $sameDir[0]
}
$picked = ($Found | Sort-Object)[0]
Write-Warning "convert-to-okf.ps1: ambiguous wikilink target '$Target' ($($Found.Count) matches: $($Found -join ', ')) — defaulted to '$picked'; verify manually."
return $picked
}

function Resolve-BareWikilinkTarget {
param([string] $Target, [string] $RepoRoot, [string] $CurrentDir)

$index = Get-WikilinkIndex -RepoRoot $RepoRoot
$key = $Target.ToLowerInvariant()
if (-not $index.ContainsKey($key)) { return $null }

$found = $index[$key]
if ($found.Count -eq 1) { return $found[0] }
return Resolve-AmbiguousMatch -Found $found -Target $Target -CurrentDir $CurrentDir
}

function Resolve-PathWikilinkTarget {
param([string] $Target, [string] $RepoRoot, [string] $CurrentDir)

# Obsidian same-vault resolution is relative to the linking note's own directory tree
# (in this brain, effectively the initiative root), not the whole bundle root. Walk up
# from the linking file's own directory, trying "$Target.md" against each ancestor in
# turn, so "adr/0001-x" resolves against the initiative that actually owns it instead
# of colliding with a same-named file in an unrelated initiative, or double-counting a
# path segment ($CurrentDir already inside "adr/") that a naive join would repeat.
$dir = $CurrentDir
while ($true) {
if ($dir) {
$candidate = Join-Path $RepoRoot ("$dir/$Target.md" -replace '/', [IO.Path]::DirectorySeparatorChar)
if (Test-Path -LiteralPath $candidate) { return "$dir/$Target.md" }
}
if (-not $dir) { break }
$parent = if ($dir -match '^(.*)/[^/]+$') { $Matches[1] } else { '' }
if ($parent -eq $dir) { break }
$dir = $parent
}

# No ancestor-relative match — widen to a basename lookup across the whole repo,
# preferring a candidate whose real path still ends with the target's own path
# segment over one that merely shares its final filename.
$bareName = ($Target -split '/')[-1]
$index = Get-WikilinkIndex -RepoRoot $RepoRoot
$key = $bareName.ToLowerInvariant()
if (-not $index.ContainsKey($key)) { return $null }

$found = $index[$key]
# Ordinal EndsWith, not -like — $Target is raw wikilink text and may legally contain
# wildcard-meaningful characters (e.g. an unclosed '[' from a Markdown-ish source), which
# -like would either match unintended candidates or throw WildcardPatternException.
$suffix = "/$Target.md"
$preserving = @($found | Where-Object { "/$_".EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase) })
if ($preserving.Count -eq 1) { return $preserving[0] }
if ($preserving.Count -gt 1) { return Resolve-AmbiguousMatch -Found $preserving -Target $Target -CurrentDir $CurrentDir }

if ($found.Count -eq 1) { return $found[0] }
return Resolve-AmbiguousMatch -Found $found -Target $Target -CurrentDir $CurrentDir
}

function Format-WikilinkTarget {
param([string] $Target)
param([string] $Target, [string] $RepoRoot, [string] $CurrentDir)

# Split off a #fragment before appending .md, so the anchor doesn't get folded into
# the filename, then re-append the fragment after. Split at the FIRST '#' so a nested
Expand Down Expand Up @@ -95,16 +200,33 @@ function Format-WikilinkTarget {
# — leave its extension as-is instead of appending a wrong ".md" suffix.
return "/$Target$fragment"
}
if ($RepoRoot) {
# A wikilink target's path segment (if any) is Obsidian-vault-relative, which in
# this brain's layout usually means relative to the initiative directory, not the
# bundle root — so treating it as bundle-root-relative literally is only safe when
# that literal path actually exists. When it doesn't, fall back to a basename
# lookup keyed on just the last path segment (same as the no-slash case) rather
# than assuming the wrong base and emitting a dead link.
$literalMdPath = Join-Path $RepoRoot ("$Target.md" -replace '/', [IO.Path]::DirectorySeparatorChar)
if (-not (Test-Path -LiteralPath $literalMdPath)) {
$resolved = if ($Target -match '/') {
Resolve-PathWikilinkTarget -Target $Target -RepoRoot $RepoRoot -CurrentDir $CurrentDir
} else {
Resolve-BareWikilinkTarget -Target $Target -RepoRoot $RepoRoot -CurrentDir $CurrentDir
}
if ($resolved) { return "/$resolved$fragment" }
}
}
return "/$Target.md$fragment"
}

function Convert-WikilinksInSegment {
param([string] $Text)
param([string] $Text, [string] $RepoRoot, [string] $CurrentDir)

# [[a/b|label]] -> [label](/a/b.md) — must run before the bare-link pattern.
$Text = [regex]::Replace($Text, '\[\[([^\]\|]+)\|([^\]]+)\]\]', {
param($m)
"[$($m.Groups[2].Value)]($(Format-WikilinkTarget $m.Groups[1].Value))"
"[$($m.Groups[2].Value)]($(Format-WikilinkTarget $m.Groups[1].Value $RepoRoot $CurrentDir))"
})

# [[a/b]] -> [b](/a/b.md)
Expand All @@ -120,14 +242,14 @@ function Convert-WikilinksInSegment {
} else {
''
}
"[$label]($(Format-WikilinkTarget $rawTarget))"
"[$label]($(Format-WikilinkTarget $rawTarget $RepoRoot $CurrentDir))"
})

return $Text
}

function Convert-Wikilinks {
param([string] $Text)
param([string] $Text, [string] $RepoRoot, [string] $CurrentDir)

# Skip fenced code blocks (``` or ~~~ fences, 3+ delimiters) and inline code spans
# (backtick runs of 1+) — wikilink-looking text quoted in a code sample or in prose
Expand All @@ -147,11 +269,11 @@ function Convert-Wikilinks {
$lastIndex = 0
foreach ($m in [regex]::Matches($Text, $pattern)) {
$prose = $Text.Substring($lastIndex, $m.Index - $lastIndex)
[void]$sb.Append((Convert-WikilinksInSegment -Text $prose))
[void]$sb.Append((Convert-WikilinksInSegment -Text $prose -RepoRoot $RepoRoot -CurrentDir $CurrentDir))
[void]$sb.Append($m.Value)
$lastIndex = $m.Index + $m.Length
}
[void]$sb.Append((Convert-WikilinksInSegment -Text $Text.Substring($lastIndex)))
[void]$sb.Append((Convert-WikilinksInSegment -Text $Text.Substring($lastIndex) -RepoRoot $RepoRoot -CurrentDir $CurrentDir))
return $sb.ToString()
}

Expand Down Expand Up @@ -265,8 +387,10 @@ function ConvertTo-OkfFile {
} else {
$FilePath
}
$currentDir = (Split-Path -Parent $relativePath) -replace '\\', '/'
if ($currentDir -eq '.') { $currentDir = '' }
$fm = Get-FrontMatter -Content $original
$fm.Body = Convert-Wikilinks -Text $fm.Body
$fm.Body = Convert-Wikilinks -Text $fm.Body -RepoRoot $repoRoot -CurrentDir $currentDir
$lines = [System.Collections.Generic.List[string]]::new()
$lines.AddRange([string[]]$fm.Lines)

Expand Down
95 changes: 95 additions & 0 deletions tests/convert-to-okf.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,101 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' {
(Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[bar](/research/bar.md)'))
}

It 'resolves a bare-filename wikilink target (no path segment) to its real on-disk location, not the bundle root' {
$script:Repo = New-TestRepo
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'tickets') -Force | Out-Null
$file = Join-Path $script:Repo 'tickets/index.md'
Set-Content -LiteralPath $file -Value "See [[T9a-ado-prerequisites]]." -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'tickets/T9a-ado-prerequisites.md') -Value 'target' -NoNewline -Encoding utf8
Add-Commit -Repo $script:Repo

Invoke-Convert -Path $script:Repo | Should -Be 0
$content = Get-Content -LiteralPath $file -Raw
$content | Should -Match ([regex]::Escape('[T9a-ado-prerequisites](/tickets/T9a-ado-prerequisites.md)'))
}

It 'resolves a path-containing wikilink target whose path segment is relative to the wrong base, not the bundle root' {
$script:Repo = New-TestRepo
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/foo/adr') -Force | Out-Null
$file = Join-Path $script:Repo 'initiatives/foo/core.md'
Set-Content -LiteralPath $file -Value "See [[adr/0001-decision]]." -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/foo/adr/0001-decision.md') -Value 'target' -NoNewline -Encoding utf8
Add-Commit -Repo $script:Repo

Invoke-Convert -Path $script:Repo | Should -Be 0
$content = Get-Content -LiteralPath $file -Raw
$content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/foo/adr/0001-decision.md)'))
}

It 'resolves a path-containing target to the initiative matching its own path segment, not a same-basename file in another initiative' {
$script:Repo = New-TestRepo
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/foo/adr') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/bar/adr') -Force | Out-Null
$file = Join-Path $script:Repo 'initiatives/foo/core.md'
Set-Content -LiteralPath $file -Value "See [[adr/0001-decision]]." -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/foo/adr/0001-decision.md') -Value 'foo target' -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/bar/adr/0001-decision.md') -Value 'bar target' -NoNewline -Encoding utf8
Add-Commit -Repo $script:Repo

Invoke-Convert -Path $script:Repo | Should -Be 0
$content = Get-Content -LiteralPath $file -Raw
$content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/foo/adr/0001-decision.md)'))
$content | Should -Not -Match ([regex]::Escape('/initiatives/bar/'))
}

It 'resolves an ambiguous bare-filename target to the same-directory match when one exists' {
$script:Repo = New-TestRepo
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/foo/adr') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/bar/adr') -Force | Out-Null
$file = Join-Path $script:Repo 'initiatives/foo/adr/index.md'
Set-Content -LiteralPath $file -Value "See [[0001-decision]]." -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/foo/adr/0001-decision.md') -Value 'foo target' -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/bar/adr/0001-decision.md') -Value 'bar target' -NoNewline -Encoding utf8
Add-Commit -Repo $script:Repo

Invoke-Convert -Path $file | Should -Be 0
$content = Get-Content -LiteralPath $file -Raw
$content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/foo/adr/0001-decision.md)'))
}

It 'resolves an ambiguous bare-filename target with no same-directory match to the sorted-first candidate' {
$script:Repo = New-TestRepo
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/aaa/adr') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'initiatives/zzz/adr') -Force | Out-Null
$file = Join-Path $script:Repo 'core.md'
Set-Content -LiteralPath $file -Value "See [[0001-decision]]." -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/aaa/adr/0001-decision.md') -Value 'aaa target' -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'initiatives/zzz/adr/0001-decision.md') -Value 'zzz target' -NoNewline -Encoding utf8
Add-Commit -Repo $script:Repo

Invoke-Convert -Path $file | Should -Be 0
$content = Get-Content -LiteralPath $file -Raw
$content | Should -Match ([regex]::Escape('[0001-decision](/initiatives/aaa/adr/0001-decision.md)'))
}

It 'keeps a path-containing wikilink target as-is when it already resolves literally from the bundle root' {
$script:Repo = New-TestRepo
New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null
$file = Join-Path $script:Repo 'core.md'
Set-Content -LiteralPath $file -Value "See [[adr/0001-decision]]." -NoNewline -Encoding utf8
Set-Content -LiteralPath (Join-Path $script:Repo 'adr/0001-decision.md') -Value 'target' -NoNewline -Encoding utf8
Add-Commit -Repo $script:Repo

Invoke-Convert -Path $file | Should -Be 0
$content = Get-Content -LiteralPath $file -Raw
$content | Should -Match ([regex]::Escape('[0001-decision](/adr/0001-decision.md)'))
}

It 'falls back to bundle-root-relative when a bare-filename wikilink target has no matching file on disk' {
$script:Repo = New-TestRepo
$file = Join-Path $script:Repo 'core.md'
Set-Content -LiteralPath $file -Value "See [[nonexistent-target]]." -NoNewline -Encoding utf8
Add-Commit -Repo $script:Repo

Invoke-Convert -Path $file | Should -Be 0
(Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[nonexistent-target](/nonexistent-target.md)'))
}

It 'does not double-append .md to a bare wikilink target that already ends in .md' {
$script:Repo = New-TestRepo
$file = Join-Path $script:Repo 'core.md'
Expand Down