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/ai-agents/skills/project-brain/SKILL.md b/ai-agents/skills/project-brain/SKILL.md index c61ad5ae..308a5e3a 100644 --- a/ai-agents/skills/project-brain/SKILL.md +++ b/ai-agents/skills/project-brain/SKILL.md @@ -24,9 +24,71 @@ 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..54e1ecd1 --- /dev/null +++ b/ai-agents/skills/project-brain/scripts/convert-to-okf.ps1 @@ -0,0 +1,384 @@ +#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 — + 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. + 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). 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 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:`/ + `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 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. 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 '^([^#]*)#(.*)$') { + $Target = $Matches[1] + $slug = $Matches[2].ToLower() -replace '[^a-z0-9]+', '-' + $slug = $slug.Trim('-') + if ($slug) { + $fragment = "#$slug" + } + } + $Target = $Target -replace '\.md$', '' + 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 + } + 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" +} + +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)]($(Format-WikilinkTarget $m.Groups[1].Value))" + }) + + # [[a/b]] -> [b](/a/b.md) + $Text = [regex]::Replace($Text, '\[\[([^\]\|]+)\]\]', { + param($m) + $rawTarget = $m.Groups[1].Value + $labelSource = $rawTarget -replace '#.*$', '' -replace '\.md$', '' + $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 +} + +function Convert-Wikilinks { + param([string] $Text) + + # 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 + } + [void]$sb.Append((Convert-WikilinksInSegment -Text $Text.Substring($lastIndex))) + return $sb.ToString() +} + +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 = $(if ($Matches[2]) { $Matches[2] } else { '' }) + } + } + 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 '\\', '/' + $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 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 + $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")" +} + +function ConvertTo-OkfFile { + [CmdletBinding(SupportsShouldProcess)] + param([string] $FilePath) + + $original = Get-Content -LiteralPath $FilePath -Raw + if ($null -eq $original) { $original = '' } + + $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 $relativePath + + if ($type -and -not (Test-TopLevelKey -Lines $lines -Key 'type')) { + $lines.Add("type: $type") + } + + 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') + $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) + } + } + } + } + + if ($type -in @('adr', 'research') -and -not (Test-TopLevelKey -Lines $lines -Key 'verified')) { + $lines.Add('verified: []') + } + + 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") + } + } + } + } + + # 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) { + "---$eol$newFrontMatter$eol---$eol$($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/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..567f1821 --- /dev/null +++ b/docs/adr/adopt-okf-for-project-brain-markdown.md @@ -0,0 +1,138 @@ +# 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. `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 + 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. diff --git a/tests/convert-to-okf.Tests.ps1 b/tests/convert-to-okf.Tests.ps1 new file mode 100644 index 00000000..922fc587 --- /dev/null +++ b/tests/convert-to-okf.Tests.ps1 @@ -0,0 +1,596 @@ +#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 '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 '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 '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' { + $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' + 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 + $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 '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 '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' + # 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 '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' + 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 '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 '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 "(?&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 '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 + $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 + $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$' + } +}