From a77c02df2d4558751f18796c8eec671090a94e94 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 01:05:02 +1000 Subject: [PATCH 01/12] docs(adr): record OKF adoption decision for project-brain markdown Resolves the design-pass questions #195 flagged as open: type: enum per directory role, ADR status remapped to draft/stable/deprecated, wikilink migration folded into scope, whole-brain-as-one-bundle, generated:/verified: with conservative non-fabricated provenance, and the shared-conversion-script approach for the mechanical parts of the migration. Also adds two CONTEXT.md glossary entries (OKF bundle, OKF concept). Refs: AB#196 --- CONTEXT.md | 8 ++ .../adopt-okf-for-project-brain-markdown.md | 135 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 docs/adr/adopt-okf-for-project-brain-markdown.md diff --git a/CONTEXT.md b/CONTEXT.md index 48cf08a9..4626ae7c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,3 +23,11 @@ _Avoid_: de-AI checklist, tell removal (the skill explicitly rejects "checklist" **Document Modes**: The `write` skill's second layer: its four Modes (Long-form Article, Release Note Template, Document Review, Paragraph Coherence), each doing a different kind of prose-document work — structural cuts, templating, a review checklist, flow diagnosis. Fire only on genuine prose documents — reports, docs, README, release notes, articles — never on single-line artifacts like a commit message or code comment. _Avoid_: prose polishing, structural pass (the skill already uses "polish" for a different, sentence-level meaning, and "structural" only describes one of the four Modes) + +**OKF bundle**: +The Open Knowledge Format (Google, v0.2) unit a single `okf_version` declaration governs. Per `project-brain`'s adoption (#195), the whole brain repo (e.g. `E:\Personal Projects\brain\`) is one bundle — its root `index.md` declares `okf_version: "0.2"`; per-initiative directories (`initiatives//`) are not separate bundles and get no `index.md` of their own. +_Avoid_: brain (ambiguous — could mean the repo, an initiative, or the concept generally); OKF instance + +**OKF concept**: +A single Markdown file with a `type:` frontmatter field, per the Open Knowledge Format. Every non-reserved `.md` file under a brain (`core.md`, `STATUS.md`, `adr/*.md`, `research/*.md`, `reports/*.md`) is a concept; `index.md` and `log.md` are reserved filenames and are not concepts. +_Avoid_: document, page (too generic — "concept" is OKF's own term and is what a consumer routes on via `type:`) diff --git a/docs/adr/adopt-okf-for-project-brain-markdown.md b/docs/adr/adopt-okf-for-project-brain-markdown.md new file mode 100644 index 00000000..e5a3f9ef --- /dev/null +++ b/docs/adr/adopt-okf-for-project-brain-markdown.md @@ -0,0 +1,135 @@ +# Adopt the Open Knowledge Format for `project-brain` markdown + +## Status + +Accepted. Governs the frontmatter schema, link syntax, and bundle structure of every +`project-brain` file (`core.md`, `STATUS.md`, `adr/*.md`, `research/*.md`, `reports/*.md`, +area-level `index.md`) across all registered brain repos. + +## Context + +`project-brain` (`ai-agents/skills/project-brain/SKILL.md`) already independently arrived +at a shape structurally similar to Google's Open Knowledge Format (OKF v0.2, June 2026): +a directory of Markdown files, `index.md`/`log.md` as reserved-role filenames, and +`core.md`/`STATUS.md` already carrying a `type:` frontmatter field. OKF formalizes this +pattern into a documented, vendor-neutral spec — adopting it where it fits trades a +bespoke schema for one other tools can also read/write, at no runtime/SDK/service cost +(OKF is "a format, not a platform"). + +Filed as #195. The real gaps against OKF conformance turned out wider than the issue +originally scoped: `templates/adr.md` had no frontmatter at all (the one gap the issue +named), but `core.md`'s "Map" section and the area-level `index.md` also use +Obsidian-style `[[wikilink]]` syntax throughout — not OKF's markdown-link form +(`[text](/path)` absolute-bundle-relative, or `[text](./path)` relative) — a gap the issue +didn't call out. + +The live decision was how much of OKF to adopt: minimal conformance only (a `type:` field +on every file) versus also adopting the optional provenance/lifecycle/versioning fields +(`generated`, `verified`, `status`, `stale_after`, bundle-root `okf_version`). + +## Decision + +Adopt OKF beyond minimal conformance, with type-specific choices below. Two registered +brain repos are migrated in the same pass: `E:\Personal Projects\brain\` and +`E:\HollardInsuranceRetail\brain\`. `session-start.ps1` needs no change — confirmed it +reads `core.md`/`STATUS.md` as raw whole-file text, never parses frontmatter fields, so +this migration is additive-only. + +- **`type:` values**: `core`, `status`, `adr`, `research`, `report` — one non-reserved + concept type per existing directory role. `index.md`/`log.md` stay reserved (no `type:`). + Extended during ticket breakdown once the Hollard brain's real shape was inventoried: + `ticket` (its per-initiative `tickets/` directories, a role not previously documented + in `SKILL.md`), `spike` (`spikes/`, a role `SKILL.md` already named but never assigned + a `type:`), `learner` (area-level `learner.md`, the `/walkthrough` skill's learner + profile), and `kanban` (one ad hoc `kanban.md` found in a single initiative) — extended + rather than left on the old schema, consistent with the `tickets/` precedent. +- **ADR frontmatter absorbs the whole bullet-list header** (`Status`/`Date`/`Scope`/ + `Supersedes`), not just `type:` — `date`, `scope`, `supersedes`, `superseded_by` all + become YAML fields, matching how `core.md`/`STATUS.md` already put metadata in + frontmatter rather than the body. +- **ADR status remaps to OKF's `status: draft | stable | deprecated` enum** rather than + keeping the existing `Proposed | Accepted | Superseded by ADR-XXXX` vocabulary as a + custom field: `Proposed → draft`, `Accepted → stable`, `Superseded → deprecated`. + `Proposed`/`draft` has no live usage today (both existing ADRs are `Accepted`), but the + mapping is kept for when a proposal stage is used. Supersession detail (which ADR) + lives in `supersedes`/`superseded_by`, not folded into `status`. +- **`research/*.md` and `reports/*.md` get their own `type:` frontmatter**, not just an + entry in their directory's `index.md` — every non-reserved file is independently + conformant, not only the ones an index links to. +- **`stale_after` is added alongside `updated:`** on `STATUS.md`, computed at edit time + (`updated:` + 7 days) — makes staleness machine-checkable without dropping the existing + "if `updated:` is >7 days old, distrust this" convention. +- **`generated: { by, at }` is added to every conformant type** (`core`, `status`, `adr`, + `research`, `report`) — mechanical, written once at file creation, no ambiguity about + when to write it. **Backfilled onto ~266 pre-existing files, `at` is never stamped with + today's migration date** — that would record the migration event, not original + authorship, the opposite of what the field means. The shared conversion script (below) + derives `at` from `git log --diff-filter=A --format=%aI -- ` where git history has + it, and omits the field entirely where it doesn't; OKF tolerates missing optional + fields, so an omitted `generated:` stays conformant. +- **`verified:` is never backfilled** — every migrated file gets `verified: []` + regardless of what its prose claims. An agent reading someone else's "verified by + spike" text and writing a `verified:` entry for it would be attesting to a + re-confirmation the migrating agent never performed. The field is populated only + going forward, when a later session genuinely re-confirms a research finding + (spike/primary-source) or re-reads and re-confirms an ADR. +- **The mechanical parts (wikilink rewrite, `type:`/`stale_after:` insertion, provenance + derivation) are done by one shared conversion script**, authored and reviewed once + under the schema ticket, rather than freehand per migration batch — guarantees + identical link-syntax and frontmatter formatting across ~266 files instead of drifting + across however many agents touch them. +- **`verified: [{ by, at, ... }]` is added only to `adr` and `research`**, with a defined + trigger so the field gets populated rather than sitting empty: on `research/*.md`, when + a finding is confirmed by spike/primary-source rather than literature review alone + (already an informal distinction in `research/index.md` prose, e.g. "verified by spike, + not just researched" — now migrated into frontmatter); on `adr/*.md`, when a later + session re-reads an ADR and confirms it still holds. +- **The whole brain repo is one OKF bundle** — only the brain-root `index.md` declares + `okf_version: "0.2"`. Per-initiative directories (`initiatives//`) are not separate + bundles and get no `index.md` of their own; `core.md`/`STATUS.md` stay the entry points. +- **`[[wikilink]]` syntax migrates to OKF's markdown-link form** across `core.md`'s Map + section, `index.md`, and ADR cross-references — folded into #195 rather than split into + a separate issue, since it's a genuine cross-link conformance gap. + +## Execution notes (ticket breakdown) + +Discovered only once the tickets were being dispatched, not during the grilling session: + +- **`jinyeow/dotfiles` is a public repo.** The Hollard migration tickets (#198-204) + originally named real PBI numbers and person-identifying initiative folders + (`793186-thilina-access`, `801442-801443-hngo-sdb-access`, etc.) in their public titles + and bodies. Redacted to generic labels (Initiative A, B, C, ...) with the real + local-path mapping kept out of the public tracker. +- **The Hollard brain repo has a single working tree, no worktree fleet** — its 7 + migration batches (#198-204) cannot run as parallel subagents each committing + independently (git `index.lock` collision, interleaved staged changes). They run + sequentially, one commit per batch. Only #197 (a separate repo, the personal brain) is + genuinely parallel with the Hollard batches. +- **`review-fix-loop` has no target in the brain repos** — `project-brain`'s own update + contract commits straight to `main`, no branch/PR flow. Cross-model review + (`--reviewers fable,sol --codex`) is scoped to #196's dotfiles-repo PR + (`SKILL.md` + templates + the shared conversion script); the brain-repo migrations + (#197-204) are verified by re-running the script's own checks plus a final + `grep -r '\[\['` sweep for leftover wikilinks, not by review-fix-loop. + +## Alternatives considered + +- **Minimal conformance only** (`type:` on every file, fix `templates/adr.md`, stop + there) — rejected. The optional fields (`status`, `stale_after`, provenance) each had a + concrete use once examined against this brain's actual update contract, and the + wikilink gap needed fixing regardless of how much of the optional spec was adopted. +- **Keep ADR's own `Proposed | Accepted | Superseded` vocabulary as a custom field** + instead of remapping to OKF's `status` enum — rejected. Direct remap keeps one `status` + vocabulary across the whole brain rather than a `type`-specific one, and supersession + detail needs its own field either way. +- **Per-initiative OKF bundles** (an `index.md` with `okf_version` inside each + `initiatives//`) — rejected. Initiatives aren't shared or exported independently + today; one bundle-root declaration is sufficient and avoids adding files with no + present consumer. +- **Skip `generated`/`verified` entirely** (single-author brain, no per-file authorship + distinction needed) — rejected for `verified` once a real trigger was identified + (research spike-vs-literature, ADR re-confirmation); `generated` was accepted outright + since it's mechanical with no ambiguity. +- **Backfill only the dotfiles-repo templates now, leave the live brain repos on the old + schema until a follow-up session** — rejected. Doing both brain repos in the same pass + avoids leaving two brains on different schemas indefinitely. From d04e6487e51c45b19b94d32bc81ed51254eab18b Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 01:17:54 +1000 Subject: [PATCH 02/12] feat(project-brain): define OKF schema in SKILL.md + shared conversion script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/adr/adopt-okf-for-project-brain-markdown.md: - SKILL.md documents the type: enum, per-type frontmatter shape, the [[wikilink]] -> markdown-link convention, and links the ADR - new scripts/convert-to-okf.ps1: the shared mechanical conversion (#197-204 all run this) — wikilink rewrite, type:/stale_after: insertion, verified: [] insertion (adr/research), generated.at provenance from git log --diff-filter=A (omitted, never guessed, when git history has none); idempotent - Pester tests covering both wikilink forms, git-log-present/absent provenance, and idempotency Closes #196 Refs: AB#196 --- ai-agents/skills/project-brain/SKILL.md | 61 ++++- .../project-brain/scripts/convert-to-okf.ps1 | 228 ++++++++++++++++++ tests/convert-to-okf.Tests.ps1 | 169 +++++++++++++ 3 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 create mode 100644 tests/convert-to-okf.Tests.ps1 diff --git a/ai-agents/skills/project-brain/SKILL.md b/ai-agents/skills/project-brain/SKILL.md index c61ad5ae..4a76259c 100644 --- a/ai-agents/skills/project-brain/SKILL.md +++ b/ai-agents/skills/project-brain/SKILL.md @@ -24,9 +24,68 @@ and regenerate-able; the brain's truth source is the work. Design rationale: ``. + ## Loading (how context reaches a session) In Claude Code, the SessionStart hook auto-injects the resolved `core.md` + `STATUS.md` (on diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 new file mode 100644 index 00000000..ce91bb64 --- /dev/null +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -0,0 +1,228 @@ +#Requires -Version 7 +<# +.SYNOPSIS + Converts project-brain markdown files to OKF v0.2 conformance (#196). + +.DESCRIPTION + Shared, reviewed-once mechanical conversion run by every brain-repo migration batch + (#197-204) instead of each batch hand-formatting the same transform. Per + docs/adr/adopt-okf-for-project-brain-markdown.md, it does exactly four things: + + 1. Rewrites Obsidian-style wikilinks to OKF markdown-link form: + [[a/b|label]] -> [label](/a/b.md) + [[a/b]] -> [b](/a/b.md) + 2. Inserts a `type:` frontmatter field, inferred from the file's role (core.md, + STATUS.md, adr/, research/, reports/, tickets/, spikes/, learner.md, kanban.md). + `index.md` and `log.md` are reserved role filenames and get no `type:`. + 3. On `status`-typed files, computes `stale_after:` as `updated:` + 7 days. + 4. On `adr`/`research`-typed files, adds an empty `verified: []` if absent. + 5. Derives `generated.at` from the file's own add commit in its own repo + (`git log --diff-filter=A --follow --format=%aI`), taking the oldest such event. + Omitted entirely when git history has none — never stamped with today's date, + which would record the migration event, not original authorship. `generated.by` + is NOT derived here: there is no reliable record of which agent/session + originally authored a pre-existing file, so guessing would fabricate provenance. + New files created from `templates/` fill both `by` and `at` by hand at scaffold + time instead (see templates/core.md, templates/STATUS.md). + + `verified:` is never populated with real entries by this script — only ever inserted + empty. A later session that genuinely re-confirms a research finding or re-reads an + ADR writes into it by hand. + + Every insertion is additive and keyed on the target field's absence, so running this + script twice over the same file makes no further change (idempotent) — safe to + re-run per migration batch without risk of duplicate frontmatter blocks. + + Out of scope (left to each migration batch, #197-204): converting an ADR's existing + bullet-list header (Status/Date/Scope/Supersedes) into `status:`/`date:`/`scope:`/ + `supersedes:`/`superseded_by:` frontmatter fields. That per-file remap is judgement + work (mapping the old Proposed|Accepted|Superseded vocabulary), not mechanical. + +.PARAMETER Path + A single markdown file, or a directory to process recursively (all *.md files under it). + +.EXAMPLE + ./convert-to-okf.ps1 -Path 'E:\Personal Projects\brain' + ./convert-to-okf.ps1 -Path 'E:\Personal Projects\brain\initiatives\dotfiles\core.md' +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] + [string] $Path +) + +$ErrorActionPreference = 'Stop' + +function Convert-Wikilinks { + param([string] $Text) + + # [[a/b|label]] -> [label](/a/b.md) — must run before the bare-link pattern. + $Text = [regex]::Replace($Text, '\[\[([^\]\|]+)\|([^\]]+)\]\]', { + param($m) + "[$($m.Groups[2].Value)](/$($m.Groups[1].Value).md)" + }) + + # [[a/b]] -> [b](/a/b.md) + $Text = [regex]::Replace($Text, '\[\[([^\]\|]+)\]\]', { + param($m) + $target = $m.Groups[1].Value + $label = ($target -split '/')[-1] + "[$label](/$target.md)" + }) + + return $Text +} + +function Get-FrontMatter { + param([string] $Content) + + if ($Content -match '(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$') { + return [PSCustomObject]@{ + HasFrontMatter = $true + Lines = @($Matches[1] -split '\r?\n') + Body = $Matches[2] + } + } + return [PSCustomObject]@{ + HasFrontMatter = $false + Lines = @() + Body = $Content + } +} + +function Test-TopLevelKey { + param([string[]] $Lines, [string] $Key) + return [bool]($Lines | Where-Object { $_ -match "^$Key\s*:" }) +} + +function Get-TopLevelValue { + param([string[]] $Lines, [string] $Key) + $line = $Lines | Where-Object { $_ -match "^$Key\s*:\s*(.*)$" } | Select-Object -First 1 + if ($null -eq $line) { return $null } + if ($line -match "^$Key\s*:\s*(.*)$") { return $Matches[1].Trim() } + return $null +} + +function Get-OkfType { + param([string] $RelativePath) + + $norm = ($RelativePath -replace '\\', '/') + $base = Split-Path $norm -Leaf + + if ($norm -match '(^|/)templates/') { return $null } + if ($base -eq 'index.md' -or $base -eq 'log.md') { return $null } + if ($base -eq 'core.md') { return 'core' } + if ($base -eq 'STATUS.md') { return 'status' } + if ($base -eq 'learner.md') { return 'learner' } + if ($base -eq 'kanban.md') { return 'kanban' } + if ($norm -match '(^|/)adr/') { return 'adr' } + if ($norm -match '(^|/)research/') { return 'research' } + if ($norm -match '(^|/)reports/') { return 'report' } + if ($norm -match '(^|/)tickets/') { return 'ticket' } + if ($norm -match '(^|/)spikes/') { return 'spike' } + return $null +} + +function Find-GitRoot { + param([string] $StartDir) + + $dir = $StartDir + while ($dir) { + if (Test-Path -LiteralPath (Join-Path $dir '.git')) { return $dir } + $parent = Split-Path -Parent $dir + if (-not $parent -or $parent -eq $dir) { return $null } + $dir = $parent + } + return $null +} + +function Get-GitAddedDate { + param([string] $RepoRoot, [string] $FilePath) + + $relative = [IO.Path]::GetRelativePath($RepoRoot, $FilePath) -replace '\\', '/' + $dates = @(& git -C $RepoRoot log --diff-filter=A --follow --format=%aI -- $relative 2>$null) + if (-not $dates -or $dates.Count -eq 0) { return $null } + # git log lists newest first; the oldest add event is original authorship. + return $dates[-1] +} + +function ConvertTo-OkfFile { + [CmdletBinding(SupportsShouldProcess)] + param([string] $FilePath) + + $original = Get-Content -LiteralPath $FilePath -Raw + if ($null -eq $original) { $original = '' } + + $relativeForType = $FilePath + $body = Convert-Wikilinks -Text $original + $fm = Get-FrontMatter -Content $body + $lines = [System.Collections.Generic.List[string]]::new() + $lines.AddRange([string[]]$fm.Lines) + + $type = Get-OkfType -RelativePath $relativeForType + + if ($type -and -not (Test-TopLevelKey -Lines $lines -Key 'type')) { + $lines.Add("type: $type") + } + + if ($type -eq 'status' -and -not (Test-TopLevelKey -Lines $lines -Key 'stale_after')) { + $updatedRaw = Get-TopLevelValue -Lines $lines -Key 'updated' + if ($updatedRaw) { + $parsedDate = [datetime]::MinValue + if ([datetime]::TryParse($updatedRaw, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$parsedDate)) { + $staleAfter = $parsedDate.AddDays(7).ToString('yyyy-MM-dd') + $lines.Add("stale_after: $staleAfter") + } + } + } + + if ($type -in @('adr', 'research') -and -not (Test-TopLevelKey -Lines $lines -Key 'verified')) { + $lines.Add('verified: []') + } + + if ($type -and -not (Test-TopLevelKey -Lines $lines -Key 'generated')) { + $repoRoot = Find-GitRoot -StartDir (Split-Path -Parent $FilePath) + if ($repoRoot) { + $at = Get-GitAddedDate -RepoRoot $repoRoot -FilePath $FilePath + if ($at) { + $lines.Add('generated:') + $lines.Add(" at: $at") + } + } + } + + $newFrontMatter = ($lines -join "`n") + $newContent = if ($lines.Count -gt 0) { + "---`n$newFrontMatter`n---`n$($fm.Body)" + } else { + $fm.Body + } + + $changed = $newContent -ne $original + if ($changed -and $PSCmdlet.ShouldProcess($FilePath, 'Convert to OKF')) { + Set-Content -LiteralPath $FilePath -Value $newContent -NoNewline -Encoding utf8 + } + + return [PSCustomObject]@{ + Path = $FilePath + Type = $type + Changed = $changed + } +} + +if (-not (Test-Path -LiteralPath $Path)) { + throw "convert-to-okf.ps1: path not found: $Path" +} + +$item = Get-Item -LiteralPath $Path +$files = if ($item.PSIsContainer) { + Get-ChildItem -LiteralPath $Path -Filter '*.md' -File -Recurse +} else { + @($item) +} + +$results = foreach ($f in $files) { + ConvertTo-OkfFile -FilePath $f.FullName +} + +$results diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 new file mode 100644 index 00000000..c7219d02 --- /dev/null +++ b/tests/convert-to-okf.Tests.ps1 @@ -0,0 +1,169 @@ +#Requires -Version 7 +# Behavioural tests for ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 — the shared +# mechanical OKF conversion script (#196) every brain-repo migration batch (#197-204) runs +# instead of hand-formatting the same transform. Drives the real script as a subprocess against +# throwaway git-repo fixtures, per this repo's existing hook-test convention +# (tests/memory-review-nudge.Tests.ps1, tests/ctags-hook.Tests.ps1). + +BeforeAll { + $script:RepoRoot = Split-Path $PSScriptRoot -Parent + $script:Script = Join-Path $script:RepoRoot 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' + + if (-not (Test-Path -LiteralPath $script:Script)) { + throw "convert-to-okf.Tests.ps1: script not found: $script:Script" + } + + function New-TestRepo { + $root = Join-Path ([IO.Path]::GetTempPath()) ('okf-' + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $root -Force | Out-Null + & git -C $root init -q . 2>&1 | Out-Null + & git -C $root config user.email 'test@example.invalid' 2>&1 | Out-Null + & git -C $root config user.name 'Test' 2>&1 | Out-Null + return $root + } + + function Add-Commit { + param([string] $Repo, [string] $Message = 'commit') + & git -C $Repo add -A 2>&1 | Out-Null + & git -C $Repo commit -q -m $Message 2>&1 | Out-Null + } + + function Invoke-Convert { + param([string] $Path) + & pwsh -NoProfile -File $script:Script -Path $Path 2>&1 | Out-Null + return $LASTEXITCODE + } +} + +Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { + AfterEach { + if ($script:Repo -and (Test-Path -LiteralPath $script:Repo)) { + Remove-Item -LiteralPath $script:Repo -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It 'rewrites [[a/b|label]] wikilinks to markdown-link form' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[adr/0001-foo|the decision]]." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[the decision](/adr/0001-foo.md)')) + } + + It 'rewrites bare [[a/b]] wikilinks, using the last path segment as the label' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[research/bar]]." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[bar](/research/bar.md)')) + } + + It 'inserts type: frontmatter inferred from the file role' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null + $file = Join-Path $script:Repo 'adr/0001-foo.md' + Set-Content -LiteralPath $file -Value "# ADR-0001 - foo`n`n- Status: Accepted" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match '(?m)^type: adr$' + } + + It 'derives generated.at from the file''s own add commit when git history has it' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`n---`n`nbody" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo -Message 'add core.md' + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Match '(?m)^generated:$' + $content | Should -Match '(?m)^\s+at: \d{4}-\d{2}-\d{2}T' + } + + It 'omits generated.at entirely when git history has no add commit for the file' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + # No git commit at all — the file is untracked. + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`n---`n`nbody" -NoNewline -Encoding utf8 + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Not -Match '(?m)^generated:$' + } + + It 'never populates verified: with content — only ever inserts it empty' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'research') -Force | Out-Null + $file = Join-Path $script:Repo 'research/finding.md' + Set-Content -LiteralPath $file -Value "# Finding`n`nVerified by spike." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match '(?m)^verified: \[\]$' + } + + It 'computes stale_after as updated: + 7 days on status-typed files' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'STATUS.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: status`nupdated: 2026-01-01`n---`n`nNow" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match '(?m)^stale_after: 2026-01-08$' + } + + It 'does not add type: to reserved index.md / log.md filenames' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'index.md' + Set-Content -LiteralPath $file -Value "# Brain index`n`nSee [[adr/0001-foo]]." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Not -Match '(?m)^type:' + $content | Should -Match ([regex]::Escape('[0001-foo](/adr/0001-foo.md)')) + } + + It 'is idempotent: running it twice produces no further change' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null + $core = Join-Path $script:Repo 'core.md' + $status = Join-Path $script:Repo 'STATUS.md' + $adr = Join-Path $script:Repo 'adr/0001-foo.md' + Set-Content -LiteralPath $core -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`n---`n`nSee [[adr/0001-foo|the decision]]." -NoNewline -Encoding utf8 + Set-Content -LiteralPath $status -Value "---`ninitiative: x`ntype: status`nupdated: 2026-01-01`n---`n`nNow" -NoNewline -Encoding utf8 + Set-Content -LiteralPath $adr -Value "# ADR-0001 - foo`n`n- Status: Accepted" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $script:Repo | Should -Be 0 + $firstPass = @{ + core = Get-Content -LiteralPath $core -Raw + status = Get-Content -LiteralPath $status -Raw + adr = Get-Content -LiteralPath $adr -Raw + } + + Invoke-Convert -Path $script:Repo | Should -Be 0 + (Get-Content -LiteralPath $core -Raw) | Should -Be $firstPass.core + (Get-Content -LiteralPath $status -Raw) | Should -Be $firstPass.status + (Get-Content -LiteralPath $adr -Raw) | Should -Be $firstPass.adr + } + + It 'processes a directory recursively' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null + $core = Join-Path $script:Repo 'core.md' + $adr = Join-Path $script:Repo 'adr/0001-foo.md' + Set-Content -LiteralPath $core -Value "See [[adr/0001-foo]]." -NoNewline -Encoding utf8 + Set-Content -LiteralPath $adr -Value "# ADR-0001 - foo" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $script:Repo | Should -Be 0 + (Get-Content -LiteralPath $core -Raw) | Should -Match ([regex]::Escape('[0001-foo](/adr/0001-foo.md)')) + (Get-Content -LiteralPath $adr -Raw) | Should -Match '(?m)^type: adr$' + } +} From 298a239b7d5ca63395f01a09d659cb44a18538e9 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 01:31:58 +1000 Subject: [PATCH 03/12] docs(project-brain): document generated.by omission on backfilled files [#f205-6] SKILL.md's frontmatter-shape table documented generated: { by, at } unconditionally; a backfilled file may legitimately carry { at } only when original authorship can't be attributed. Clarified in SKILL.md and the ADR so a conformance check against either doc doesn't flag every migrated file. Refs: AB#196 --- ai-agents/skills/project-brain/SKILL.md | 5 ++++- docs/adr/adopt-okf-for-project-brain-markdown.md | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/ai-agents/skills/project-brain/SKILL.md b/ai-agents/skills/project-brain/SKILL.md index 4a76259c..308a5e3a 100644 --- a/ai-agents/skills/project-brain/SKILL.md +++ b/ai-agents/skills/project-brain/SKILL.md @@ -70,7 +70,10 @@ reserved role filenames and carry no `type:`. `generated.by`/`generated.at` are written once, by hand, at file-creation time for new files (the placeholder in each template). They are never backfilled with today's date on a pre-existing file — -see the conversion script below. `verified:` is never backfilled with real entries either: every +see the conversion script below. A backfilled (pre-existing) file may therefore carry +`generated: { at }` only, with `by` omitted rather than guessed, when the original authoring +agent/session can't be reliably attributed — this is conformant, not a gap to flag. `verified:` is +never backfilled with real entries either: every migrated file gets `verified: []` regardless of what its prose claims; it is populated only going forward, by a session that actually re-performs the confirmation. diff --git a/docs/adr/adopt-okf-for-project-brain-markdown.md b/docs/adr/adopt-okf-for-project-brain-markdown.md index e5a3f9ef..567f1821 100644 --- a/docs/adr/adopt-okf-for-project-brain-markdown.md +++ b/docs/adr/adopt-okf-for-project-brain-markdown.md @@ -66,7 +66,10 @@ this migration is additive-only. authorship, the opposite of what the field means. The shared conversion script (below) derives `at` from `git log --diff-filter=A --format=%aI -- ` where git history has it, and omits the field entirely where it doesn't; OKF tolerates missing optional - fields, so an omitted `generated:` stays conformant. + fields, so an omitted `generated:` stays conformant. `by` is never derived or backfilled + on a pre-existing file — there is no reliable record of which agent/session originally + authored it, so a backfilled file may carry `generated: { at }` only, and that is + conformant, not a gap. - **`verified:` is never backfilled** — every migrated file gets `verified: []` regardless of what its prose claims. An agent reading someone else's "verified by spike" text and writing a `verified:` entry for it would be attesting to a From c902aa061f862cac3bcf03a51439eab047a01201 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 01:39:40 +1000 Subject: [PATCH 04/12] fix(project-brain): stop wikilink rewrite corrupting suffixed/code targets [#f205-2, #f205-3] Convert-Wikilinks double-appended .md onto targets already ending in .md and folded #anchor fragments into the filename before appending .md, both breaking the resulting link. It also ran over the raw whole-file text, so it could rewrite wikilink-looking text inside inline code, fenced code, or frontmatter. Added Format-WikilinkTarget to strip an existing .md suffix and re-append a #fragment after .md instead of before. Split the body on code spans/fences before transforming (prose only), and reordered ConvertTo-OkfFile to run the transform on the frontmatter-split body so frontmatter is never touched. 7 new tests, 17/17 green. Refs: AB#196 --- .../project-brain/scripts/convert-to-okf.ps1 | 44 +++++++++-- tests/convert-to-okf.Tests.ps1 | 77 +++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index ce91bb64..00d7aac2 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -53,26 +53,56 @@ param( $ErrorActionPreference = 'Stop' -function Convert-Wikilinks { +function Format-WikilinkTarget { + param([string] $Target) + + # Split off a #fragment before appending .md, so the anchor doesn't get folded into + # the filename, then re-append the fragment after. Strip an existing .md suffix from + # the target first, so a target that already ends in .md doesn't get a second one. + $fragment = '' + if ($Target -match '^(.*)#(.*)$') { + $Target = $Matches[1] + $fragment = "#$($Matches[2])" + } + $Target = $Target -replace '\.md$', '' + return "$Target.md$fragment" +} + +function Convert-WikilinksInSegment { param([string] $Text) # [[a/b|label]] -> [label](/a/b.md) — must run before the bare-link pattern. $Text = [regex]::Replace($Text, '\[\[([^\]\|]+)\|([^\]]+)\]\]', { param($m) - "[$($m.Groups[2].Value)](/$($m.Groups[1].Value).md)" + "[$($m.Groups[2].Value)](/$(Format-WikilinkTarget $m.Groups[1].Value))" }) # [[a/b]] -> [b](/a/b.md) $Text = [regex]::Replace($Text, '\[\[([^\]\|]+)\]\]', { param($m) - $target = $m.Groups[1].Value - $label = ($target -split '/')[-1] - "[$label](/$target.md)" + $rawTarget = $m.Groups[1].Value + $labelSource = $rawTarget -replace '#.*$', '' -replace '\.md$', '' + $label = ($labelSource -split '/')[-1] + "[$label](/$(Format-WikilinkTarget $rawTarget))" }) return $Text } +function Convert-Wikilinks { + param([string] $Text) + + # Skip fenced code blocks (```...```) and inline code spans (`...`) — wikilink-looking + # text quoted in a code sample or in prose about the syntax itself must not be rewritten. + # regex.Split with a capturing group interleaves the pieces: even indexes are prose + # (transformable), odd indexes are the code spans/blocks themselves (left untouched). + $parts = [regex]::Split($Text, '(```[\s\S]*?```|`[^`\r\n]*`)') + for ($i = 0; $i -lt $parts.Count; $i += 2) { + $parts[$i] = Convert-WikilinksInSegment -Text $parts[$i] + } + return ($parts -join '') +} + function Get-FrontMatter { param([string] $Content) @@ -154,8 +184,8 @@ function ConvertTo-OkfFile { if ($null -eq $original) { $original = '' } $relativeForType = $FilePath - $body = Convert-Wikilinks -Text $original - $fm = Get-FrontMatter -Content $body + $fm = Get-FrontMatter -Content $original + $fm.Body = Convert-Wikilinks -Text $fm.Body $lines = [System.Collections.Generic.List[string]]::new() $lines.AddRange([string[]]$fm.Lines) diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index c7219d02..04b0b848 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -62,6 +62,83 @@ 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 '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' + Set-Content -LiteralPath $file -Value "See [[adr/foo.md]]." -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('[foo](/adr/foo.md)')) + $content | Should -Not -Match ([regex]::Escape('.md.md')) + } + + It 'does not double-append .md to a labeled wikilink target that already ends in .md' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[adr/foo.md|Label]]." -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('[Label](/adr/foo.md)')) + $content | Should -Not -Match ([regex]::Escape('.md.md')) + } + + It 'preserves a #anchor fragment on a bare wikilink target' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[a/b#section]]." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[b](/a/b.md#section)')) + } + + It 'preserves a #anchor fragment on a labeled wikilink target' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[a/b#section|Label]]." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[Label](/a/b.md#section)')) + } + + It 'leaves wikilink-looking text inside inline code spans untouched' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'index.md' + Set-Content -LiteralPath $file -Value 'Use `[[a/b]]` syntax for links.' -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Be 'Use `[[a/b]]` syntax for links.' + } + + It 'leaves wikilink-looking text inside fenced code blocks untouched' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'index.md' + $value = "Example:`n" + '```' + "`nSee [[a/b]] here.`n" + '```' + "`nDone." + Set-Content -LiteralPath $file -Value $value -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Be $value + } + + It 'leaves wikilink-looking text inside YAML frontmatter untouched' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + $value = "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`nnote: `"See [[a/b]] here.`"`n---`n`nbody" + Set-Content -LiteralPath $file -Value $value -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('note: "See [[a/b]] here."')) + } + It 'inserts type: frontmatter inferred from the file role' { $script:Repo = New-TestRepo New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null From ffe6f36949a52efb69ac8403c1644d5450c15bbb Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 01:47:29 +1000 Subject: [PATCH 05/12] fix(project-brain): recompute stale_after on every rerun [#f205-4] stale_after was only inserted when absent, so a later STATUS.md edit to updated: was never reflected on rerun -- the old stale_after value stayed in place, understating or overstating staleness. Now always recomputed from the current updated: value and replaced in place when present. 2 new tests (recompute-on-change, no-op when updated: is unchanged), 19/19 green. Refs: AB#196 --- .../project-brain/scripts/convert-to-okf.ps1 | 24 +++++++++---- tests/convert-to-okf.Tests.ps1 | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index 00d7aac2..ab01767d 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -14,7 +14,8 @@ 2. Inserts a `type:` frontmatter field, inferred from the file's role (core.md, STATUS.md, adr/, research/, reports/, tickets/, spikes/, learner.md, kanban.md). `index.md` and `log.md` are reserved role filenames and get no `type:`. - 3. On `status`-typed files, computes `stale_after:` as `updated:` + 7 days. + 3. On `status`-typed files, computes `stale_after:` as `updated:` + 7 days — + recomputed (in place) on every run, so a later `updated:` edit moves it too. 4. On `adr`/`research`-typed files, adds an empty `verified: []` if absent. 5. Derives `generated.at` from the file's own add commit in its own repo (`git log --diff-filter=A --follow --format=%aI`), taking the oldest such event. @@ -29,9 +30,11 @@ empty. A later session that genuinely re-confirms a research finding or re-reads an ADR writes into it by hand. - Every insertion is additive and keyed on the target field's absence, so running this - script twice over the same file makes no further change (idempotent) — safe to - re-run per migration batch without risk of duplicate frontmatter blocks. + Every insertion is additive and keyed on the target field's absence (`stale_after:` is + the exception — it is always recomputed from the current `updated:` value), so running + this script twice over the same file with `updated:` unchanged makes no further change + (idempotent) — safe to re-run per migration batch without risk of duplicate frontmatter + blocks. Out of scope (left to each migration batch, #197-204): converting an ADR's existing bullet-list header (Status/Date/Scope/Supersedes) into `status:`/`date:`/`scope:`/ @@ -195,13 +198,22 @@ function ConvertTo-OkfFile { $lines.Add("type: $type") } - if ($type -eq 'status' -and -not (Test-TopLevelKey -Lines $lines -Key 'stale_after')) { + if ($type -eq 'status') { $updatedRaw = Get-TopLevelValue -Lines $lines -Key 'updated' if ($updatedRaw) { $parsedDate = [datetime]::MinValue if ([datetime]::TryParse($updatedRaw, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$parsedDate)) { $staleAfter = $parsedDate.AddDays(7).ToString('yyyy-MM-dd') - $lines.Add("stale_after: $staleAfter") + $staleAfterLine = "stale_after: $staleAfter" + $existingIndex = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^stale_after\s*:') { $existingIndex = $i; break } + } + if ($existingIndex -ge 0) { + $lines[$existingIndex] = $staleAfterLine + } else { + $lines.Add($staleAfterLine) + } } } } diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index 04b0b848..76b65e86 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -194,6 +194,41 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { (Get-Content -LiteralPath $file -Raw) | Should -Match '(?m)^stale_after: 2026-01-08$' } + It 'recomputes stale_after when updated: changes on a rerun' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'STATUS.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: status`nupdated: 2026-01-01`n---`n`nNow" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match '(?m)^stale_after: 2026-01-08$' + + $content = Get-Content -LiteralPath $file -Raw + $content = $content -replace 'updated: 2026-01-01', 'updated: 2026-02-01' + Set-Content -LiteralPath $file -Value $content -NoNewline -Encoding utf8 + + Invoke-Convert -Path $file | Should -Be 0 + $result = Get-Content -LiteralPath $file -Raw + $result | Should -Match '(?m)^stale_after: 2026-02-08$' + $result | Should -Not -Match '(?m)^stale_after: 2026-01-08$' + } + + It 'is a no-op rerun (changed: $false) when updated: is unchanged' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'STATUS.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: status`nupdated: 2026-01-01`n---`n`nNow" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + $firstPass = Get-Content -LiteralPath $file -Raw + + $json = & pwsh -NoProfile -Command "& '$script:Script' -Path '$file' | ConvertTo-Json" + $LASTEXITCODE | Should -Be 0 + $result = $json | ConvertFrom-Json + $result.Changed | Should -Be $false + (Get-Content -LiteralPath $file -Raw) | Should -Be $firstPass + } + It 'does not add type: to reserved index.md / log.md filenames' { $script:Repo = New-TestRepo $file = Join-Path $script:Repo 'index.md' From f416bb513cf52a517a8c1c505e3666fce734c47c Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 01:54:49 +1000 Subject: [PATCH 06/12] fix(project-brain): type-match files by repo-relative path, not absolute [#f205-1] ConvertTo-OkfFile passed the absolute file path into Get-OkfType via a dead-alias variable, so its role-directory patterns (research/, adr/, templates/, ...) matched any ancestor segment outside the brain repo -- a brain checked out under a parent directory sharing a role name would mistype or suppress conversion of the whole tree. Now computes the path relative to the git root (Find-GitRoot) once and passes that through. 1 new test (a fixture nested under an adr-named ancestor, file actually in reports/), 20/20 green. Refs: AB#196 --- .../project-brain/scripts/convert-to-okf.ps1 | 10 +++++--- tests/convert-to-okf.Tests.ps1 | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index ab01767d..5382222d 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -186,13 +186,18 @@ function ConvertTo-OkfFile { $original = Get-Content -LiteralPath $FilePath -Raw if ($null -eq $original) { $original = '' } - $relativeForType = $FilePath + $repoRoot = Find-GitRoot -StartDir (Split-Path -Parent $FilePath) + $relativePath = if ($repoRoot) { + [IO.Path]::GetRelativePath($repoRoot, $FilePath) + } else { + $FilePath + } $fm = Get-FrontMatter -Content $original $fm.Body = Convert-Wikilinks -Text $fm.Body $lines = [System.Collections.Generic.List[string]]::new() $lines.AddRange([string[]]$fm.Lines) - $type = Get-OkfType -RelativePath $relativeForType + $type = Get-OkfType -RelativePath $relativePath if ($type -and -not (Test-TopLevelKey -Lines $lines -Key 'type')) { $lines.Add("type: $type") @@ -223,7 +228,6 @@ function ConvertTo-OkfFile { } if ($type -and -not (Test-TopLevelKey -Lines $lines -Key 'generated')) { - $repoRoot = Find-GitRoot -StartDir (Split-Path -Parent $FilePath) if ($repoRoot) { $at = Get-GitAddedDate -RepoRoot $repoRoot -FilePath $FilePath if ($at) { diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index 76b65e86..f4c08d11 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -265,6 +265,29 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { (Get-Content -LiteralPath $adr -Raw) | Should -Be $firstPass.adr } + It 'types a file by its path relative to the brain root, not by an ancestor directory that happens to share a role name' { + # The fixture repo root itself sits under a parent directory segment named + # "adr" (e.g. .../Temp/adr/okf-). The file lives under the brain's own + # reports/ directory, so it must type as 'report' — matching the absolute + # path (which also contains "adr/" from the ancestor) would mistype it as + # 'adr'. + $parent = Join-Path ([IO.Path]::GetTempPath()) 'adr' + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $script:Repo = Join-Path $parent ('okf-' + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:Repo -Force | Out-Null + & git -C $script:Repo init -q . 2>&1 | Out-Null + & git -C $script:Repo config user.email 'test@example.invalid' 2>&1 | Out-Null + & git -C $script:Repo config user.name 'Test' 2>&1 | Out-Null + + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'reports') -Force | Out-Null + $file = Join-Path $script:Repo 'reports/foo.md' + Set-Content -LiteralPath $file -Value "Body." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Match '(?m)^type: report$' + } + It 'processes a directory recursively' { $script:Repo = New-TestRepo New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null From 01f514fe79bc916af46279fd266b98ee2e629a6c Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 02:03:25 +1000 Subject: [PATCH 07/12] fix(project-brain): raise on real git log failure instead of silently omitting provenance [#f205-5] Get-GitAddedDate redirected stderr to $null and never checked $LASTEXITCODE, so a genuinely broken git log call (corrupt repo, bad pathspec) was indistinguishable from the legitimate 'no history for this file' case -- both silently omitted generated.at. Now distinguishes an unborn HEAD (a real empty repo, no commits yet -- the legitimate no-history case, still returns $null) from any other git failure (throws with repo + file context) via git rev-parse --is-inside-work-tree / --verify -q HEAD rather than parsing error text. 1 new test, 21/21 green. Refs: AB#196 --- .../project-brain/scripts/convert-to-okf.ps1 | 23 +++++++++++++++---- tests/convert-to-okf.Tests.ps1 | 14 +++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index 5382222d..29f864e9 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -173,10 +173,25 @@ function Get-GitAddedDate { param([string] $RepoRoot, [string] $FilePath) $relative = [IO.Path]::GetRelativePath($RepoRoot, $FilePath) -replace '\\', '/' - $dates = @(& git -C $RepoRoot log --diff-filter=A --follow --format=%aI -- $relative 2>$null) - if (-not $dates -or $dates.Count -eq 0) { return $null } - # git log lists newest first; the oldest add event is original authorship. - return $dates[-1] + $output = @(& git -C $RepoRoot log --diff-filter=A --follow --format=%aI -- $relative 2>&1) + $logExitCode = $LASTEXITCODE + if ($logExitCode -eq 0) { + if (-not $output -or $output.Count -eq 0) { return $null } + # git log lists newest first; the oldest add event is original authorship. + return $output[-1] + } + + # git log failed. A repo with no commits yet (unborn HEAD) legitimately has no + # add-commit history for any file — that is not a tooling failure. Anything else + # (a corrupt/invalid repo, a bad pathspec, ...) is a real failure and must surface, + # not be silently treated the same as "no history". + & git -C $RepoRoot rev-parse --is-inside-work-tree 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + & git -C $RepoRoot rev-parse --verify -q HEAD 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { return $null } + } + + throw "Get-GitAddedDate: git log failed (exit $logExitCode) in repo '$RepoRoot' for file '$relative': $($output -join "`n")" } function ConvertTo-OkfFile { diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index f4c08d11..17cda4f3 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -162,6 +162,20 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { $content | Should -Match '(?m)^\s+at: \d{4}-\d{2}-\d{2}T' } + It 'raises an error rather than silently omitting generated.at when git log genuinely fails' { + $script:Repo = Join-Path ([IO.Path]::GetTempPath()) ('okf-corrupt-' + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:Repo -Force | Out-Null + # A .git directory that is not a real git repository — Find-GitRoot finds it + # (it only checks for the directory's presence), but any `git` command run + # against it fails with a non-zero exit code. This must surface as a real + # error, not be silently treated the same as "no history for this file". + New-Item -ItemType Directory -Path (Join-Path $script:Repo '.git') -Force | Out-Null + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`n---`n`nbody" -NoNewline -Encoding utf8 + + Invoke-Convert -Path $file | Should -Not -Be 0 + } + It 'omits generated.at entirely when git history has no add commit for the file' { $script:Repo = New-TestRepo $file = Join-Path $script:Repo 'core.md' From 2d74e46df133bb01ef0c0426e5aecd1d2a9fdd63 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 02:09:59 +1000 Subject: [PATCH 08/12] fix(project-brain): frontmatter round-trip robustness on EOF fence and CRLF [#f205-7, #f205-8] Get-FrontMatter's regex required a newline after the closing --- fence, so a file whose fence was the last line with no trailing newline fell through to being treated as body -- a second frontmatter block got prepended on rerun, the exact duplicate the script's idempotency docstring claims doesn't happen. Separately, the frontmatter rebuild always rejoined with a bare LF regardless of the file's actual line endings, so a CRLF file's frontmatter flipped to LF on every run while the body stayed CRLF -- mixed endings, and changed:$true forever instead of a true no-op. Fixed both: the closing-fence newline is now optional in the regex, and the rebuild detects and preserves the file's dominant line-ending style. 2 new tests, 23/23 green. Refs: AB#196 --- .../project-brain/scripts/convert-to-okf.ps1 | 13 +++++--- tests/convert-to-okf.Tests.ps1 | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index 29f864e9..27bb1151 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -109,11 +109,11 @@ function Convert-Wikilinks { function Get-FrontMatter { param([string] $Content) - if ($Content -match '(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$') { + if ($Content -match '(?s)^---\r?\n(.*?)\r?\n---(?:\r?\n(.*))?$') { return [PSCustomObject]@{ HasFrontMatter = $true Lines = @($Matches[1] -split '\r?\n') - Body = $Matches[2] + Body = $(if ($Matches[2]) { $Matches[2] } else { '' }) } } return [PSCustomObject]@{ @@ -252,9 +252,14 @@ function ConvertTo-OkfFile { } } - $newFrontMatter = ($lines -join "`n") + # Rebuild the frontmatter block using the file's own dominant line-ending style, so a + # CRLF file stays CRLF end to end (the body already keeps its original endings + # untouched) — otherwise a CRLF file would flip its frontmatter to LF on every run, + # reporting `changed: $true` forever and ending up with mixed line endings. + $eol = if ($original -match "`r`n") { "`r`n" } else { "`n" } + $newFrontMatter = ($lines -join $eol) $newContent = if ($lines.Count -gt 0) { - "---`n$newFrontMatter`n---`n$($fm.Body)" + "---$eol$newFrontMatter$eol---$eol$($fm.Body)" } else { $fm.Body } diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index 17cda4f3..8799dd3e 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -302,6 +302,39 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { (Get-Content -LiteralPath $file -Raw) | Should -Match '(?m)^type: report$' } + It 'does not duplicate frontmatter when the closing fence is the last line with no trailing newline' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + # No newline after the closing '---' fence and no body at all. + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`n---" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $fenceCount = ([regex]::Matches($content, '(?m)^---$')).Count + $fenceCount | Should -Be 2 + } + + It 'rebuilds frontmatter using CRLF line endings and stays a true no-op rerun on a CRLF file' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'STATUS.md' + $value = "---`r`ninitiative: x`r`ntype: status`r`nupdated: 2026-01-01`r`n---`r`n`r`nNow`r`n" + [IO.File]::WriteAllText($file, $value) + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + $rawBytes = Get-Content -LiteralPath $file -Raw + $rawBytes | Should -Not -Match "(? Date: Mon, 24 Aug 2026 02:17:21 +1000 Subject: [PATCH 09/12] test(project-brain): cover -WhatIf no-op, generated.by absence, templates/ exclusion [#f205-9] Closes 3 real coverage gaps against the script's documented behavior: a -WhatIf no-op assertion (SupportsShouldProcess was advertised but untested), a generated.by-absence assertion alongside the existing at:-presence test for backfilled files, and a templates/ exclusion test -- confirmed against the actual code that templates/ only excludes type-derived fields, not wikilink rewriting, and named the test to match that real behavior rather than an assumed one. 26/26 green. Refs: AB#196 --- tests/convert-to-okf.Tests.ps1 | 38 ++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index 8799dd3e..cbaea8bb 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -335,6 +335,44 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { $rerunContent | Should -Not -Match "(?&1 | Out-Null + $LASTEXITCODE | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Be $before + } + + It 'does not write generated.by alongside a backfilled generated.at' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`n---`n`nbody" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo -Message 'add core.md' + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Match '(?m)^\s+at: \d{4}-\d{2}-\d{2}T' + $content | Should -Not -Match '(?m)^\s*by\s*:' + } + + It 'does not add type: (or type-derived fields) to files under a templates/ directory, but still rewrites their wikilinks' { + $script:Repo = New-TestRepo + New-Item -ItemType Directory -Path (Join-Path $script:Repo 'templates') -Force | Out-Null + $file = Join-Path $script:Repo 'templates/core.md' + Set-Content -LiteralPath $file -Value "See [[adr/0001-foo|the decision]]." -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + $content | Should -Not -Match '(?m)^type:' + $content | Should -Not -Match '(?m)^generated:' + $content | Should -Match ([regex]::Escape('[the decision](/adr/0001-foo.md)')) + } + It 'processes a directory recursively' { $script:Repo = New-TestRepo New-Item -ItemType Directory -Path (Join-Path $script:Repo 'adr') -Force | Out-Null From b00a4dace6c632ab96055ba742926a7cb977a1a8 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 08:43:33 +1000 Subject: [PATCH 10/12] fix(project-brain): resolve wikilink reappearances -- local-heading links and delimiter run-length matching [#f205-2, #f205-3] Cycle-1's fixes for these findings were incomplete, caught on a cycle-2 re-review: - Format-WikilinkTarget still corrupted a bare local-heading link ([[#Heading]] -> [](/.md#Heading), empty label, malformed path). A target with no path segment before # isn't a cross-file link at all (OKF's link syntax only covers file references), so it's now left as a plain #fragment link instead of getting a .md path. - The code/frontmatter skip only recognized a literal triple-backtick fence and single-backtick span, missing tilde fences, longer fences wrapping a shorter example, and double-backtick spans. Replaced with run-length-aware matching (backreference to the opening delimiter's exact run length) via manual Matches + StringBuilder reconstruction, since multiple capture groups broke regex.Split's even/odd interleaving. 5 new tests (bare heading x2, tilde fence, 4-backtick-wrapping-3, double-backtick span), 31/31 green. Known pre-existing gap flagged, not fixed: an unclosed fence with no closing delimiter before EOF falls through as prose (same in the original code, unlikely in well-formed brain files). Refs: AB#196 --- .../project-brain/scripts/convert-to-okf.ps1 | 53 +++++++++++++---- tests/convert-to-okf.Tests.ps1 | 57 +++++++++++++++++++ 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index 27bb1151..ad2d88e5 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -68,7 +68,14 @@ function Format-WikilinkTarget { $fragment = "#$($Matches[2])" } $Target = $Target -replace '\.md$', '' - return "$Target.md$fragment" + if (-not $Target) { + # [[#Heading]] — a bare local-heading link with no path segment before the + # fragment. OKF's link syntax covers cross-file references, not intra-file + # headings, so there is no filename to fold ".md" into; emit a plain in-page + # anchor instead of the malformed "/.md#Heading". + return $fragment + } + return "/$Target.md$fragment" } function Convert-WikilinksInSegment { @@ -77,7 +84,7 @@ function Convert-WikilinksInSegment { # [[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))" }) # [[a/b]] -> [b](/a/b.md) @@ -85,8 +92,15 @@ function Convert-WikilinksInSegment { param($m) $rawTarget = $m.Groups[1].Value $labelSource = $rawTarget -replace '#.*$', '' -replace '\.md$', '' - $label = ($labelSource -split '/')[-1] - "[$label](/$(Format-WikilinkTarget $rawTarget))" + $label = if ($labelSource) { + ($labelSource -split '/')[-1] + } elseif ($rawTarget -match '^#(.*)$') { + # Bare [[#Heading]] with no explicit label — fall back to the heading text. + $Matches[1] + } else { + '' + } + "[$label]($(Format-WikilinkTarget $rawTarget))" }) return $Text @@ -95,15 +109,30 @@ function Convert-WikilinksInSegment { function Convert-Wikilinks { param([string] $Text) - # Skip fenced code blocks (```...```) and inline code spans (`...`) — wikilink-looking - # text quoted in a code sample or in prose about the syntax itself must not be rewritten. - # regex.Split with a capturing group interleaves the pieces: even indexes are prose - # (transformable), odd indexes are the code spans/blocks themselves (left untouched). - $parts = [regex]::Split($Text, '(```[\s\S]*?```|`[^`\r\n]*`)') - for ($i = 0; $i -lt $parts.Count; $i += 2) { - $parts[$i] = Convert-WikilinksInSegment -Text $parts[$i] + # 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 + # about the syntax itself must not be rewritten. Per CommonMark, a fence's closing + # delimiter run must be at least as long as its opening run, and an inline span's + # closing run must match the opening run's exact backtick count — otherwise a longer + # fence wrapping a shorter literal example (e.g. a 4-backtick fence containing a + # ``` example) closes early, and a double-backtick span is misparsed as an empty pair. + # Named groups capture each opening run so \k<...> backreferences require a matching + # closing run of the same character; the trailing `* / ~* absorbs a closing run that is + # longer than the opening, per the "at least as long" rule. + $pattern = '(?`{3,})[\s\S]*?\k`*' + + '|(?~{3,})[\s\S]*?\k~*' + + '|(?`+)[^\r\n]*?\k' + + $sb = [System.Text.StringBuilder]::new() + $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($m.Value) + $lastIndex = $m.Index + $m.Length } - return ($parts -join '') + [void]$sb.Append((Convert-WikilinksInSegment -Text $Text.Substring($lastIndex))) + return $sb.ToString() } function Get-FrontMatter { diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index cbaea8bb..ed976413 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -106,6 +106,63 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { (Get-Content -LiteralPath $file -Raw) | Should -Match ([regex]::Escape('[Label](/a/b.md#section)')) } + It 'converts a bare local-heading wikilink [[#Heading]] to a plain anchor link, not .md#Heading' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[#Background]] above." -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('[Background](#Background)')) + $content | Should -Not -Match ([regex]::Escape('.md#Background')) + } + + It 'converts a labeled local-heading wikilink [[#Heading|Label]] to a plain anchor link, not .md#Heading' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[#Background|this section]] above." -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('[this section](#Background)')) + $content | Should -Not -Match ([regex]::Escape('.md#Background')) + } + + It 'leaves wikilink-looking text inside a tilde-fenced code block untouched' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'index.md' + $value = "Example:`n" + '~~~' + "`nSee [[a/b]] here.`n" + '~~~' + "`nDone." + Set-Content -LiteralPath $file -Value $value -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Be $value + } + + It 'leaves wikilink-looking text untouched inside a 4-backtick fence wrapping a literal triple-backtick example' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'index.md' + $value = "Example:`n" + '````' + "`nHere is a snippet:`n" + '```' + "`nSee [[a/b]] here.`n" + '```' + "`nmore text`n" + '````' + "`nDone." + Set-Content -LiteralPath $file -Value $value -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Be $value + } + + It 'leaves wikilink-looking text inside a double-backtick inline code span untouched' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'index.md' + $value = 'Use ``[[a/b]]`` syntax for links.' + Set-Content -LiteralPath $file -Value $value -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo + + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Be $value + } + It 'leaves wikilink-looking text inside inline code spans untouched' { $script:Repo = New-TestRepo $file = Join-Path $script:Repo 'index.md' From aceacc4eb244f62cb6a7e607071ffc63991f707e Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 09:02:15 +1000 Subject: [PATCH 11/12] fix(project-brain): tighten unborn-HEAD scope and backfill generated.at into an incomplete block [#f205-12, #f205-13] f205-5's fix distinguished unborn-HEAD from a real git-log failure, but treated ANY failure of the verify-HEAD check as the legitimate unborn case -- a narrower version of the original silent-swallow bug survived inside its own fix. Now requires both symbolic-ref -q HEAD to succeed and verify -q HEAD to fail before treating it as unborn; anything else (corrupt HEAD content, a valid HEAD pointing at a corrupt branch ref) falls through to the existing throw. Separately, a file with an existing generated: block containing only by: (no at:) never got at: backfilled -- the insertion logic only handled the no-block-at-all case. Now detects an existing generated: mapping (block or inline form) missing at: and adds it in place. 5 new tests (corrupt HEAD content, corrupt branch-target ref, block-style and inline-style at: backfill, existing legitimate- unborn-HEAD case reconfirmed unregressed), 35/35 green. Refs: AB#196 --- .../project-brain/scripts/convert-to-okf.ps1 | 67 +++++++++++++++--- tests/convert-to-okf.Tests.ps1 | 68 +++++++++++++++++++ 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index ad2d88e5..ec9b251b 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -24,17 +24,21 @@ is NOT derived here: there is no reliable record of which agent/session originally authored a pre-existing file, so guessing would fabricate provenance. New files created from `templates/` fill both `by` and `at` by hand at scaffold - time instead (see templates/core.md, templates/STATUS.md). + time instead (see templates/core.md, templates/STATUS.md). If the file already + has a `generated:` mapping (block-style `generated:\n by: ...` or inline + `generated: { by: ... }`) that is missing `at:`, `at:` is backfilled into that + same mapping rather than left incomplete or duplicated. `verified:` is never populated with real entries by this script — only ever inserted empty. A later session that genuinely re-confirms a research finding or re-reads an ADR writes into it by hand. - Every insertion is additive and keyed on the target field's absence (`stale_after:` is - the exception — it is always recomputed from the current `updated:` value), so running - this script twice over the same file with `updated:` unchanged makes no further change - (idempotent) — safe to re-run per migration batch without risk of duplicate frontmatter - blocks. + Every insertion is additive and keyed on the target field's absence — `stale_after:` + is always recomputed from the current `updated:` value, and `generated.at` is keyed on + its own absence rather than the whole `generated:` mapping's absence (see point 5) — so + running this script twice over the same file with `updated:` unchanged makes no + further change (idempotent) — safe to re-run per migration batch without risk of + duplicate frontmatter blocks or fields. Out of scope (left to each migration batch, #197-204): converting an ADR's existing bullet-list header (Status/Date/Scope/Supersedes) into `status:`/`date:`/`scope:`/ @@ -212,12 +216,21 @@ function Get-GitAddedDate { # git log failed. A repo with no commits yet (unborn HEAD) legitimately has no # add-commit history for any file — that is not a tooling failure. Anything else - # (a corrupt/invalid repo, a bad pathspec, ...) is a real failure and must surface, - # not be silently treated the same as "no history". + # (a corrupt/invalid repo, a corrupt HEAD ref, a bad pathspec, ...) is a real failure + # and must surface, not be silently treated the same as "no history". + # + # `rev-parse --verify -q HEAD` failing is not proof of legitimate unborn HEAD on its + # own — a corrupt HEAD ref fails that same check. A genuinely unborn HEAD is also a + # *valid symbolic ref* (HEAD points at a real branch name that simply has no commits + # yet), so require both: `symbolic-ref -q HEAD` succeeds AND `rev-parse --verify -q + # HEAD` fails. Anything else falls through to the throw below. & git -C $RepoRoot rev-parse --is-inside-work-tree 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { + & git -C $RepoRoot symbolic-ref -q HEAD 2>&1 | Out-Null + $isValidSymbolicRef = ($LASTEXITCODE -eq 0) & git -C $RepoRoot rev-parse --verify -q HEAD 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { return $null } + $verifyFailed = ($LASTEXITCODE -ne 0) + if ($isValidSymbolicRef -and $verifyFailed) { return $null } } throw "Get-GitAddedDate: git log failed (exit $logExitCode) in repo '$RepoRoot' for file '$relative': $($output -join "`n")" @@ -271,13 +284,45 @@ function ConvertTo-OkfFile { $lines.Add('verified: []') } - if ($type -and -not (Test-TopLevelKey -Lines $lines -Key 'generated')) { - if ($repoRoot) { + if ($type -and $repoRoot) { + $generatedIndex = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^generated\s*:') { $generatedIndex = $i; break } + } + + if ($generatedIndex -lt 0) { $at = Get-GitAddedDate -RepoRoot $repoRoot -FilePath $FilePath if ($at) { $lines.Add('generated:') $lines.Add(" at: $at") } + } elseif ($lines[$generatedIndex] -match '^generated\s*:\s*\{(.*)\}\s*$') { + # Inline-map style: generated: { by: ... }. Backfill at: into the same map + # if it is missing, rather than leaving the block incomplete. + $inner = $Matches[1] + if ($inner -notmatch '(^|,)\s*at\s*:') { + $at = Get-GitAddedDate -RepoRoot $repoRoot -FilePath $FilePath + if ($at) { + $trimmedInner = $inner.Trim() + $newInner = if ($trimmedInner) { "$trimmedInner, at: $at" } else { "at: $at" } + $lines[$generatedIndex] = "generated: { $newInner }" + } + } + } else { + # Block style: generated: on its own line, children indented below it. + # Backfill at: into that same block if it is missing. + $blockEnd = $generatedIndex + 1 + $hasAt = $false + while ($blockEnd -lt $lines.Count -and $lines[$blockEnd] -match '^\s+\S') { + if ($lines[$blockEnd] -match '^\s+at\s*:') { $hasAt = $true } + $blockEnd++ + } + if (-not $hasAt) { + $at = Get-GitAddedDate -RepoRoot $repoRoot -FilePath $FilePath + if ($at) { + $lines.Insert($blockEnd, " at: $at") + } + } } } diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index ed976413..eb6a71e8 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -233,6 +233,39 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { Invoke-Convert -Path $file | Should -Not -Be 0 } + It 'raises an error rather than silently treating a corrupt HEAD (not merely unborn) as an empty repo' { + $script:Repo = New-TestRepo + # A real, valid work tree with no commits yet has an unborn-but-legitimate HEAD: + # `git symbolic-ref -q HEAD` succeeds (HEAD is a valid symref to a branch that + # simply has no commits yet), while `rev-parse --verify -q HEAD` fails (no commit + # to resolve to). Point HEAD at a malformed ref name instead — `rev-parse --verify + # -q HEAD` still fails the same way, but `git symbolic-ref -q HEAD` now fails too + # ("your current branch appears to be broken"), which is what distinguishes a + # genuinely corrupt HEAD from legitimate unborn HEAD. This must surface as a real + # error, not be silently swallowed as "no history". + Set-Content -LiteralPath (Join-Path $script:Repo '.git/HEAD') -Value 'ref: refs/heads/bad ref name' -NoNewline -Encoding utf8 + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`n---`n`nbody" -NoNewline -Encoding utf8 + + Invoke-Convert -Path $file | Should -Not -Be 0 + } + + It 'raises an error when HEAD itself is a valid symref but the branch it targets is corrupt' { + $script:Repo = New-TestRepo + # A different corruption shape than the previous test: HEAD's own content is a + # well-formed symref ("ref: refs/heads/main"), but the branch ref it points at + # holds garbage instead of a commit SHA. `git symbolic-ref -q HEAD` must also + # fail to resolve this (not just report HEAD's literal text), or this would be + # misclassified as legitimate unborn HEAD the same way f205-12 originally did. + $branch = & git -C $script:Repo symbolic-ref -q HEAD + New-Item -ItemType Directory -Path (Join-Path $script:Repo ".git/$(Split-Path $branch -Parent)") -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:Repo ".git/$branch") -Value 'notahexsha' -NoNewline -Encoding utf8 + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`n---`n`nbody" -NoNewline -Encoding utf8 + + Invoke-Convert -Path $file | Should -Not -Be 0 + } + It 'omits generated.at entirely when git history has no add commit for the file' { $script:Repo = New-TestRepo $file = Join-Path $script:Repo 'core.md' @@ -416,6 +449,41 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { $content | Should -Not -Match '(?m)^\s*by\s*:' } + It 'backfills at: into an existing generated: block that only has by:, without duplicating the block' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`ngenerated:`n by: someone`n---`n`nbody" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo -Message 'add core.md' + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + ([regex]::Matches($content, '(?m)^generated:\s*$')).Count | Should -Be 1 + $content | Should -Match '(?m)^\s+by: someone$' + $content | Should -Match '(?m)^\s+at: \d{4}-\d{2}-\d{2}T' + + # Rerunning must be a true no-op — the backfill must not itself be a moving target. + $firstPass = $content + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Be $firstPass + } + + It 'backfills at: into an existing inline generated: { by: ... } map, without duplicating the field' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "---`ninitiative: x`ntype: core`nupdated: 2026-01-01`ngenerated: { by: someone }`n---`n`nbody" -NoNewline -Encoding utf8 + Add-Commit -Repo $script:Repo -Message 'add core.md' + + Invoke-Convert -Path $file | Should -Be 0 + $content = Get-Content -LiteralPath $file -Raw + ([regex]::Matches($content, '(?m)^generated\s*:')).Count | Should -Be 1 + $content | Should -Match '(?m)^generated: \{ by: someone, at: \d{4}-\d{2}-\d{2}T[^}]*\}$' + + # Rerunning must be a true no-op — the backfill must not itself be a moving target. + $firstPass = $content + Invoke-Convert -Path $file | Should -Be 0 + (Get-Content -LiteralPath $file -Raw) | Should -Be $firstPass + } + It 'does not add type: (or type-derived fields) to files under a templates/ directory, but still rewrites their wikilinks' { $script:Repo = New-TestRepo New-Item -ItemType Directory -Path (Join-Path $script:Repo 'templates') -Force | Out-Null From 198cba5fa1b264a9dbf45958ef5982a8a26d4b43 Mon Sep 17 00:00:00 2001 From: Justin Puah Date: Mon, 24 Aug 2026 09:24:50 +1000 Subject: [PATCH 12/12] fix(project-brain): slugify fragments, don't append .md to asset targets, split fragment at first # [#f205-16, #f205-17, #f205-18] Three more bugs in Format-WikilinkTarget found on a third independent review pass: - A wikilink fragment with spaces ([[a/b#Some Heading]], [[#My Heading]]) produced an unencoded space in the link destination, breaking CommonMark link parsing. Fragments are now slugified (lowercase, non-alphanumeric runs to a single hyphen, trimmed) to match how real Markdown renderers generate heading anchors -- a punctuation-only or empty fragment slugifies to nothing and is dropped rather than left as a dangling #. - A non-markdown asset target ([[image.png]], [[report.pdf]]) got .md wrongly appended after only a literal .md suffix was being stripped. Now any target already ending in a file extension is left untouched (no .md appended), while .md targets still have their existing suffix stripped and cleanly re-added -- not double-appended. - The fragment-split regex split at the LAST # in the target, so a nested-heading link ([[file#H1#H2]]) mis-split at the wrong point. Now splits at the first #, keeping the rest of the fragment intact. Necessarily updated two pre-existing tests whose expected output encoded the same fragment-casing bug (#Background -> #background, matching real GitHub anchor generation). 7 new tests, 42/42 green. Known accepted limitation, not fixed: the asset-extension check is a generic "ends in a dot-suffix" heuristic, so a multi-dot page name with no real extension would misclassify as an asset -- real brain filenames don't use multi-dot names, and a hardcoded extension allowlist would be speculative complexity beyond what this finding asked for. Refs: AB#196 --- .../project-brain/scripts/convert-to-okf.ps1 | 24 ++++- tests/convert-to-okf.Tests.ps1 | 90 ++++++++++++++++++- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 index ec9b251b..54e1ecd1 100644 --- a/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -64,12 +64,23 @@ function Format-WikilinkTarget { param([string] $Target) # Split off a #fragment before appending .md, so the anchor doesn't get folded into - # the filename, then re-append the fragment after. Strip an existing .md suffix from - # the target first, so a target that already ends in .md doesn't get a second one. + # the filename, then re-append the fragment after. Split at the FIRST '#' so a nested + # Obsidian heading link ([[file#H1#H2]]) keeps "H1#H2" as one fragment instead of + # losing everything up to the last '#'. Slugify the fragment (GitHub heading-anchor + # style: lowercase, non-alphanumeric runs collapsed to a single '-', leading/trailing + # '-' trimmed) so a multi-word Obsidian heading doesn't leave a raw space in the link + # destination — an unencoded space breaks CommonMark link-destination parsing. A + # fragment that slugifies to nothing (only punctuation, or empty) is dropped rather + # than emitted as a bare, unresolvable "#". Strip an existing .md suffix from the + # target first, so a target that already ends in .md doesn't get a second one. $fragment = '' - if ($Target -match '^(.*)#(.*)$') { + if ($Target -match '^([^#]*)#(.*)$') { $Target = $Matches[1] - $fragment = "#$($Matches[2])" + $slug = $Matches[2].ToLower() -replace '[^a-z0-9]+', '-' + $slug = $slug.Trim('-') + if ($slug) { + $fragment = "#$slug" + } } $Target = $Target -replace '\.md$', '' if (-not $Target) { @@ -79,6 +90,11 @@ function Format-WikilinkTarget { # anchor instead of the malformed "/.md#Heading". return $fragment } + if ($Target -match '\.[A-Za-z0-9]+$') { + # The target already names a concrete non-markdown asset file (image, PDF, etc.) + # — leave its extension as-is instead of appending a wrong ".md" suffix. + return "/$Target$fragment" + } return "/$Target.md$fragment" } diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 index eb6a71e8..922fc587 100644 --- a/tests/convert-to-okf.Tests.ps1 +++ b/tests/convert-to-okf.Tests.ps1 @@ -114,8 +114,8 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { Invoke-Convert -Path $file | Should -Be 0 $content = Get-Content -LiteralPath $file -Raw - $content | Should -Match ([regex]::Escape('[Background](#Background)')) - $content | Should -Not -Match ([regex]::Escape('.md#Background')) + $content | Should -Match ([regex]::Escape('[Background](#background)')) + $content | Should -Not -Match ([regex]::Escape('.md#background')) } It 'converts a labeled local-heading wikilink [[#Heading|Label]] to a plain anchor link, not .md#Heading' { @@ -126,8 +126,90 @@ Describe 'ai-agents/skills/project-brain/scripts/convert-to-okf.ps1' { Invoke-Convert -Path $file | Should -Be 0 $content = Get-Content -LiteralPath $file -Raw - $content | Should -Match ([regex]::Escape('[this section](#Background)')) - $content | Should -Not -Match ([regex]::Escape('.md#Background')) + $content | Should -Match ([regex]::Escape('[this section](#background)')) + $content | Should -Not -Match ([regex]::Escape('.md#background')) + } + + It 'slugifies a multi-word #anchor fragment on a bare wikilink target so the link destination has no raw space' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[a/b#Some Heading]]." -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('[b](/a/b.md#some-heading)')) + $content | Should -Not -Match ' \]\(/a/b\.md#Some Heading\)' + } + + It 'slugifies a multi-word bare local-heading wikilink [[#My Heading]]' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[#My Heading]] above." -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('[My Heading](#my-heading)')) + } + + It 'slugifies a multi-word #anchor fragment on a labeled wikilink target' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[a/b#Some Heading|Label]]." -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('[Label](/a/b.md#some-heading)')) + } + + It 'does not append .md to a bare image-asset wikilink target' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[image.png]]." -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('[image.png](/image.png)')) + $content | Should -Not -Match ([regex]::Escape('.png.md')) + } + + It 'does not append .md to a bare PDF-asset wikilink target' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[report.pdf]]." -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('[report.pdf](/report.pdf)')) + $content | Should -Not -Match ([regex]::Escape('.pdf.md')) + } + + It 'does not append .md to a labeled image-asset wikilink target' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[image.png|My Image]]." -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('[My Image](/image.png)')) + $content | Should -Not -Match ([regex]::Escape('.png.md')) + } + + It 'splits a nested-heading wikilink [[file#H1#H2]] at the first # instead of the last' { + $script:Repo = New-TestRepo + $file = Join-Path $script:Repo 'core.md' + Set-Content -LiteralPath $file -Value "See [[file#H1#H2]]." -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('(/file.md#h1-h2)')) + $content | Should -Not -Match ([regex]::Escape('.md#H1.md')) } It 'leaves wikilink-looking text inside a tilde-fenced code block untouched' {