From 910c235084cb976e6d4adca7fe962ff309c7016b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Gell=C3=A9r?= Date: Wed, 26 Aug 2026 10:17:17 +0200 Subject: [PATCH 1/7] feat: add LearningVault registration lifecycle Provide cross-platform registration, relinking, and safe restoration while keeping repository state logically isolated and local-only. Co-authored-by: Cursor --- MANIFEST.txt | 5 + sample/vault/.gitignore | 4 + sample/vault/AGENTS.md | 34 +++ sample/vault/README.md | 43 ++++ scripts/register-vault.ps1 | 432 +++++++++++++++++++++++++++++++++++++ scripts/register-vault.sh | 390 +++++++++++++++++++++++++++++++++ 6 files changed, 908 insertions(+) create mode 100644 sample/vault/.gitignore create mode 100644 sample/vault/AGENTS.md create mode 100644 sample/vault/README.md create mode 100644 scripts/register-vault.ps1 create mode 100644 scripts/register-vault.sh diff --git a/MANIFEST.txt b/MANIFEST.txt index 7bbf605..69589fa 100644 --- a/MANIFEST.txt +++ b/MANIFEST.txt @@ -94,10 +94,15 @@ sample/profiles/minimal/learning-flow/README.md 1974 sample/profiles/minimal/learning-flow/TAKEAWAYS.md 973 sample/root/AGENTS.md 2263 sample/root/AGENTS.pointer.md 712 +sample/vault/.gitignore 32 +sample/vault/AGENTS.md 1580 +sample/vault/README.md 1369 scripts/README.md 10227 scripts/install.bat 1477 scripts/install.ps1 55583 scripts/install.sh 47145 +scripts/register-vault.ps1 18800 +scripts/register-vault.sh 13633 skill-evals/README.md 1689 skill-evals/adoption-cases.yaml 1337 skill-evals/agentic-cases.yaml 9830 diff --git a/sample/vault/.gitignore b/sample/vault/.gitignore new file mode 100644 index 0000000..e1a590e --- /dev/null +++ b/sample/vault/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +Thumbs.db +*.tmp +*.bak diff --git a/sample/vault/AGENTS.md b/sample/vault/AGENTS.md new file mode 100644 index 0000000..658b0e9 --- /dev/null +++ b/sample/vault/AGENTS.md @@ -0,0 +1,34 @@ +# LearningVault agent instructions + +This repository is a local index of learning state for other source +repositories. It is not an application codebase and does not own their +implementation. + +## Boundaries + +- Reusable framework instructions and skills live under `~/.agents` + (`%USERPROFILE%\.agents` on Windows), not here. +- Each `repositories//` directory belongs logically to one + source repository. Start with its `VAULT.md`. +- `learning-flow/`, `agentic-flow/`, and `.local/` are exposed in the source + repository through directory links. Changes on either side affect the same + files. +- Root `AGENTS.md` remains physically in each source repository. +- Do not infer that every registered repository is relevant. For + cross-repository work, identify the involved repository IDs first and read + only their maps, settings, and relevant continuity. + +## Safety + +- Never create or configure a remote, commit, push, publish, or rewrite + history without explicit permission. +- Treat `.local/` as private. Do not copy secrets, customer data, raw + operational evidence, identity information, or sensitive personal state + into shared maps or takeaways. +- Git history can retain deleted content. Removing a sensitive file from the + working tree does not erase it from existing commits. +- Do not crawl every repository or load every session merely to understand + this vault. Use `repositories/*/VAULT.md` as the index. + +Use the normal Codebase Learning Flow routing from the global harness. This +file adds only the storage and cross-repository boundaries above. diff --git a/sample/vault/README.md b/sample/vault/README.md new file mode 100644 index 0000000..49b926c --- /dev/null +++ b/sample/vault/README.md @@ -0,0 +1,43 @@ +# LearningVault + +LearningVault is an optional, local-only Git repository that collects learning +state from multiple source repositories without changing the paths expected by +Codebase Learning Flow. + +Each registered repository keeps a physical root `AGENTS.md`. Its `.local/`, +`learning-flow/`, and `agentic-flow/` directories are stored here and exposed +at their original paths through a directory junction on Windows or a symbolic +link on POSIX systems. + +```text +repositories/ + / + VAULT.md + .local/ + learning-flow/ + agentic-flow/ +``` + +The reusable framework remains under `~/.agents` (`%USERPROFILE%\.agents` on +Windows). Content under `repositories/` remains logically owned by its source +repository. + +## Register a repository + +First install the global harness and create a linked repository installation. +Then run the matching script from the source repository: + +```powershell +& "$HOME\LearningVault\scripts\register-vault.ps1" register +``` + +```sh +"$HOME/LearningVault/scripts/register-vault.sh" register +``` + +Use `status` to inspect a registration, `relink` after moving the vault, and +`unregister --restore` to move the state back into the source repository. + +The scripts never add a remote, stage files, or create commits. Review the +vault before committing because its local Git history can retain deleted +private or sensitive data. diff --git a/scripts/register-vault.ps1 b/scripts/register-vault.ps1 new file mode 100644 index 0000000..9aa0798 --- /dev/null +++ b/scripts/register-vault.ps1 @@ -0,0 +1,432 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet("register", "unregister", "relink", "status")] + [string]$Action = "register", + [string]$SourcePath = (Get-Location).Path, + [string]$VaultPath = "", + [string]$RepositoryId = "", + [switch]$Restore +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$StateDirectories = @(".local", "learning-flow", "agentic-flow") +$ExcludeStart = "# codebase-learning-flow-vault:start" +$ExcludeEnd = "# codebase-learning-flow-vault:end" +$ExcludeEntries = @("/.local/", "/learning-flow/", "/agentic-flow/") + +function Write-Step([string]$Message) { + Write-Host "[learning-vault] $Message" +} + +function Invoke-Git { + param( + [string]$WorkingDirectory, + [string[]]$Arguments, + [switch]$AllowFailure + ) + + $output = @(& git -C $WorkingDirectory @Arguments 2>$null) + if ($LASTEXITCODE -ne 0 -and -not $AllowFailure) { + throw "Git command failed in $WorkingDirectory`: git $($Arguments -join ' ')" + } + if ($LASTEXITCODE -ne 0) { return @() } + return $output +} + +function Resolve-SourceRoot([string]$RequestedPath) { + if ($null -eq (Get-Command git -ErrorAction SilentlyContinue)) { + throw "Git is required to register a LearningVault repository." + } + $requested = [System.IO.Path]::GetFullPath($RequestedPath).TrimEnd('\', '/') + if (-not (Test-Path -LiteralPath $requested -PathType Container)) { + throw "Source repository does not exist: $requested" + } + $top = (Invoke-Git -WorkingDirectory $requested -Arguments @("rev-parse", "--show-toplevel") | Select-Object -First 1) + if ([string]::IsNullOrWhiteSpace($top)) { + throw "Source path is not inside a Git repository: $requested" + } + $root = [System.IO.Path]::GetFullPath($top).TrimEnd('\', '/') + if (-not $requested.Equals($root, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Run registration at the repository root ($root), or pass -SourcePath $root." + } + return $root +} + +function Resolve-VaultRoot([string]$RequestedPath) { + if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) { + return [System.IO.Path]::GetFullPath($RequestedPath).TrimEnd('\', '/') + } + if (-not [string]::IsNullOrWhiteSpace($env:CODEBASE_LEARNING_VAULT)) { + return [System.IO.Path]::GetFullPath($env:CODEBASE_LEARNING_VAULT).TrimEnd('\', '/') + } + $home = $env:USERPROFILE + if ([string]::IsNullOrWhiteSpace($home)) { $home = $env:HOME } + if ([string]::IsNullOrWhiteSpace($home)) { + throw "Cannot resolve LearningVault: pass -VaultPath or set CODEBASE_LEARNING_VAULT." + } + return Join-Path $home "LearningVault" +} + +function Initialize-VaultRepository([string]$Root) { + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { + New-Item -ItemType Directory -Path $Root -Force | Out-Null + } + $inside = Invoke-Git -WorkingDirectory $Root -Arguments @("rev-parse", "--is-inside-work-tree") -AllowFailure + if (($inside | Select-Object -First 1) -ne "true") { + & git -C $Root init | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to initialize the LearningVault Git repository at $Root." } + Write-Step "Initialized local Git repository at $Root" + } + if (@(Invoke-Git -WorkingDirectory $Root -Arguments @("remote") -AllowFailure).Count -gt 0) { + Write-Step "WARNING: this LearningVault has a Git remote. Registration will not modify it." + } + New-Item -ItemType Directory -Path (Join-Path $Root "repositories") -Force | Out-Null +} + +function Get-Sha256Prefix([string]$Value) { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { $hash = $sha.ComputeHash($bytes) } + finally { $sha.Dispose() } + return (($hash | ForEach-Object { $_.ToString("x2") }) -join "").Substring(0, 8) +} + +function ConvertTo-SafeId([string]$Value) { + $safe = $Value.ToLowerInvariant() -replace "\.git$", "" -replace "[^a-z0-9._-]+", "-" + $safe = $safe.Trim('-', '.', '_') + if ([string]::IsNullOrWhiteSpace($safe)) { return "repository" } + return $safe +} + +function Get-RepositoryIdentity([string]$SourceRoot) { + $origin = (Invoke-Git -WorkingDirectory $SourceRoot -Arguments @("config", "--get", "remote.origin.url") -AllowFailure | Select-Object -First 1) + if (-not [string]::IsNullOrWhiteSpace($origin)) { + $trimmed = $origin.Trim().TrimEnd('/').TrimEnd('\') + $name = [System.IO.Path]::GetFileNameWithoutExtension(($trimmed -replace "\\", "/")) + return [pscustomobject]@{ + Origin = $trimmed + Identity = $trimmed.ToLowerInvariant() + Name = (ConvertTo-SafeId $name) + } + } + return [pscustomobject]@{ + Origin = "" + Identity = $SourceRoot.ToLowerInvariant() + Name = (ConvertTo-SafeId (Split-Path -Leaf $SourceRoot)) + } +} + +function Get-RepositoryId([string]$SourceRoot, [string]$RequestedId) { + if (-not [string]::IsNullOrWhiteSpace($RequestedId)) { + $safe = ConvertTo-SafeId $RequestedId + if ($safe -ne $RequestedId.ToLowerInvariant()) { + throw "Repository ID contains unsupported characters: $RequestedId" + } + return $safe + } + $identity = Get-RepositoryIdentity $SourceRoot + return "$($identity.Name)-$(Get-Sha256Prefix $identity.Identity)" +} + +function Test-ReparsePoint([string]$Path) { + try { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + return ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + } + catch { return $false } +} + +function Get-LinkTargetPath([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + $target = @($item.Target) | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($target)) { return "" } + if (-not [System.IO.Path]::IsPathRooted($target)) { + $target = Join-Path (Split-Path -Parent $Path) $target + } + return [System.IO.Path]::GetFullPath($target).TrimEnd('\', '/') +} + +function Test-SamePath([string]$Left, [string]$Right) { + return ([System.IO.Path]::GetFullPath($Left).TrimEnd('\', '/')).Equals( + [System.IO.Path]::GetFullPath($Right).TrimEnd('\', '/'), + [System.StringComparison]::OrdinalIgnoreCase + ) +} + +function Test-WindowsPlatform { + return $env:OS -eq "Windows_NT" +} + +function New-StateLink([string]$Path, [string]$Target) { + if (Test-WindowsPlatform) { + New-Item -ItemType Junction -Path $Path -Target $Target | Out-Null + } + else { + New-Item -ItemType SymbolicLink -Path $Path -Target $Target | Out-Null + } +} + +function Remove-StateLink([string]$Path) { + if (-not (Test-ReparsePoint $Path)) { return } + if (Test-WindowsPlatform) { + & cmd.exe /d /c rmdir "`"$Path`"" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to remove directory junction: $Path" } + } + else { + Remove-Item -LiteralPath $Path -Force + } +} + +function Test-LinkCapability([string]$VaultRoot) { + $probeTarget = Join-Path $VaultRoot (".link-target-" + [Guid]::NewGuid().ToString("N")) + $probeLink = Join-Path $VaultRoot (".link-probe-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $probeTarget | Out-Null + try { + New-StateLink -Path $probeLink -Target $probeTarget + if (-not (Test-ReparsePoint $probeLink)) { + throw "The platform created no usable directory link." + } + } + catch { + throw "Cannot create LearningVault directory links at $VaultRoot. Verify filesystem support and link permissions. $($_.Exception.Message)" + } + finally { + if (Test-ReparsePoint $probeLink) { Remove-StateLink $probeLink } + if (Test-Path -LiteralPath $probeTarget) { Remove-Item -LiteralPath $probeTarget -Force } + } +} + +function Get-ExcludePath([string]$SourceRoot) { + $path = (Invoke-Git -WorkingDirectory $SourceRoot -Arguments @("rev-parse", "--path-format=absolute", "--git-path", "info/exclude") -AllowFailure | Select-Object -First 1) + if ([string]::IsNullOrWhiteSpace($path)) { + $path = (Invoke-Git -WorkingDirectory $SourceRoot -Arguments @("rev-parse", "--git-path", "info/exclude") | Select-Object -First 1) + if (-not [System.IO.Path]::IsPathRooted($path)) { $path = Join-Path $SourceRoot $path } + } + return [System.IO.Path]::GetFullPath($path) +} + +function Set-ExcludeBlock([string]$SourceRoot, [bool]$Present) { + $path = Get-ExcludePath $SourceRoot + $parent = Split-Path -Parent $path + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $content = if (Test-Path -LiteralPath $path -PathType Leaf) { + [System.IO.File]::ReadAllText($path) + } else { "" } + $pattern = "(?ms)^$([regex]::Escape($ExcludeStart))\r?\n.*?^$([regex]::Escape($ExcludeEnd))\r?\n?" + $content = [regex]::Replace($content, $pattern, "") + $content = $content.TrimEnd("`r", "`n") + if ($Present) { + $block = @($ExcludeStart) + $ExcludeEntries + @($ExcludeEnd) + if ($content.Length -gt 0) { $content += "`n`n" } + $content += ($block -join "`n") + } + if ($content.Length -gt 0) { $content += "`n" } + [System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false)) +} + +function Assert-LinkedInstall([string]$SourceRoot) { + $marker = Join-Path $SourceRoot "learning-flow/.install-scope" + if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { + throw "LearningVault registration requires an existing linked installation. Run the installer with -Scope Linked first." + } + $scopeLine = Get-Content -LiteralPath $marker | Where-Object { $_ -match "^scope:\s*" } | Select-Object -First 1 + $scope = if ($null -ne $scopeLine -and $scopeLine -match "^scope:\s*(.+?)\s*$") { $Matches[1].ToLowerInvariant() } else { "" } + if ($scope -ne "linked") { + throw "LearningVault registration supports linked scope only. Convert this repository with -Scope Linked -Mode Update first." + } +} + +function Assert-StateUntracked([string]$SourceRoot) { + $tracked = @() + foreach ($name in $StateDirectories) { + $tracked += @(Invoke-Git -WorkingDirectory $SourceRoot -Arguments @("ls-files", "--", $name)) + } + if ($tracked.Count -gt 0) { + throw "Refusing to vault tracked paths. Untrack or commit a deliberate repository migration first: $($tracked -join ', ')" + } +} + +function Write-VaultMetadata([string]$RegistrationRoot, [string]$Id, [string]$SourceRoot) { + $identity = Get-RepositoryIdentity $SourceRoot + $linkKind = if (Test-WindowsPlatform) { "junction" } else { "symbolic-link" } + $origin = if ([string]::IsNullOrWhiteSpace($identity.Origin)) { "(none)" } else { $identity.Origin } + $text = @' +# Vault registration: {0} + +- Repository ID: `{0}` +- Source path: `{1}` +- Origin: `{2}` +- Link kind: `{3}` + +The state below remains logically owned by the source repository. Root +`AGENTS.md` stays in that repository. Reusable framework files stay in +`~/.agents` (`%USERPROFILE%\.agents` on Windows). +'@ -f $Id, $SourceRoot, $origin, $linkKind + [System.IO.File]::WriteAllText( + (Join-Path $RegistrationRoot "VAULT.md"), + ($text.TrimEnd() + "`n"), + [System.Text.UTF8Encoding]::new($false) + ) +} + +function Find-RegistrationId([string]$SourceRoot, [string]$VaultRoot, [string]$RequestedId) { + if (-not [string]::IsNullOrWhiteSpace($RequestedId)) { return ConvertTo-SafeId $RequestedId } + foreach ($name in $StateDirectories) { + $source = Join-Path $SourceRoot $name + if (Test-ReparsePoint $source) { + $target = Get-LinkTargetPath $source + $repositoriesRoot = [System.IO.Path]::GetFullPath((Join-Path $VaultRoot "repositories")).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + if ($target.StartsWith($repositoriesRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + return ($target.Substring($repositoriesRoot.Length) -split "[\\/]")[0] + } + } + } + foreach ($metadata in Get-ChildItem -LiteralPath (Join-Path $VaultRoot "repositories") -Filter "VAULT.md" -Recurse -ErrorAction SilentlyContinue) { + if ((Get-Content -LiteralPath $metadata.FullName -Raw) -match "(?m)^- Source path: `?(.+?)`?\s*$") { + if (Test-SamePath $Matches[1] $SourceRoot) { return Split-Path -Leaf $metadata.DirectoryName } + } + } + throw "No LearningVault registration found for $SourceRoot. Pass -RepositoryId when relinking a moved repository." +} + +function Register-Repository([string]$SourceRoot, [string]$VaultRoot, [string]$Id) { + Assert-LinkedInstall $SourceRoot + Assert-StateUntracked $SourceRoot + Test-LinkCapability $VaultRoot + + $registration = Join-Path (Join-Path $VaultRoot "repositories") $Id + New-Item -ItemType Directory -Path $registration -Force | Out-Null + $moved = [System.Collections.Generic.List[object]]::new() + $createdLinks = [System.Collections.Generic.List[string]]::new() + + try { + foreach ($name in $StateDirectories) { + $source = Join-Path $SourceRoot $name + $destination = Join-Path $registration $name + + if (Test-ReparsePoint $source) { + if (-not (Test-SamePath (Get-LinkTargetPath $source) $destination)) { + throw "$source links to a different location. Use relink or unregister it first." + } + continue + } + if ((Test-Path -LiteralPath $source) -and (Test-Path -LiteralPath $destination)) { + throw "Both source and vault copies exist for $name. Refusing to merge them." + } + if (Test-Path -LiteralPath $source) { + Move-Item -LiteralPath $source -Destination $destination + $moved.Add([pscustomobject]@{ Source = $source; Destination = $destination }) + } + elseif (-not (Test-Path -LiteralPath $destination)) { + New-Item -ItemType Directory -Path $destination -Force | Out-Null + } + New-StateLink -Path $source -Target $destination + $createdLinks.Add($source) + } + } + catch { + foreach ($link in $createdLinks) { + if (Test-ReparsePoint $link) { Remove-StateLink $link } + } + for ($index = $moved.Count - 1; $index -ge 0; $index--) { + $entry = $moved[$index] + if ((Test-Path -LiteralPath $entry.Destination) -and -not (Test-Path -LiteralPath $entry.Source)) { + Move-Item -LiteralPath $entry.Destination -Destination $entry.Source + } + } + throw + } + + Set-ExcludeBlock -SourceRoot $SourceRoot -Present $true + Write-VaultMetadata -RegistrationRoot $registration -Id $Id -SourceRoot $SourceRoot + Write-Step "Registered $SourceRoot as $Id" +} + +function Relink-Repository([string]$SourceRoot, [string]$VaultRoot, [string]$Id) { + Test-LinkCapability $VaultRoot + $registration = Join-Path (Join-Path $VaultRoot "repositories") $Id + if (-not (Test-Path -LiteralPath $registration -PathType Container)) { + throw "Vault registration does not exist: $registration" + } + foreach ($name in $StateDirectories) { + $source = Join-Path $SourceRoot $name + $destination = Join-Path $registration $name + if (-not (Test-Path -LiteralPath $destination -PathType Container)) { + throw "Vault registration is missing $name`: $destination" + } + if (Test-ReparsePoint $source) { Remove-StateLink $source } + elseif (Test-Path -LiteralPath $source) { throw "Cannot relink because a real source directory exists: $source" } + New-StateLink -Path $source -Target $destination + } + Set-ExcludeBlock -SourceRoot $SourceRoot -Present $true + Write-VaultMetadata -RegistrationRoot $registration -Id $Id -SourceRoot $SourceRoot + Write-Step "Relinked $Id to $SourceRoot" +} + +function Unregister-Repository([string]$SourceRoot, [string]$VaultRoot, [string]$Id) { + if (-not $Restore) { + throw "Unregister requires -Restore so the source repository never loses its only working state." + } + $registration = Join-Path (Join-Path $VaultRoot "repositories") $Id + foreach ($name in $StateDirectories) { + $source = Join-Path $SourceRoot $name + $destination = Join-Path $registration $name + if ((Test-Path -LiteralPath $source) -and -not (Test-ReparsePoint $source)) { + throw "Cannot restore because a real source directory exists: $source" + } + if (-not (Test-Path -LiteralPath $destination -PathType Container)) { + throw "Cannot restore because the vault copy is missing: $destination" + } + } + foreach ($name in $StateDirectories) { + $source = Join-Path $SourceRoot $name + $destination = Join-Path $registration $name + if (Test-ReparsePoint $source) { Remove-StateLink $source } + Move-Item -LiteralPath $destination -Destination $source + } + Set-ExcludeBlock -SourceRoot $SourceRoot -Present $false + $metadata = Join-Path $registration "VAULT.md" + if (Test-Path -LiteralPath $metadata) { Remove-Item -LiteralPath $metadata -Force } + if ((Get-ChildItem -LiteralPath $registration -Force | Measure-Object).Count -eq 0) { + Remove-Item -LiteralPath $registration -Force + } + Write-Step "Restored $Id to $SourceRoot" +} + +function Show-Status([string]$SourceRoot, [string]$VaultRoot, [string]$Id) { + Write-Host "LearningVault: $VaultRoot" + Write-Host "Repository: $SourceRoot" + Write-Host "Registration: $Id" + foreach ($name in $StateDirectories) { + $source = Join-Path $SourceRoot $name + if (Test-ReparsePoint $source) { + Write-Host ("{0,-14} linked -> {1}" -f $name, (Get-LinkTargetPath $source)) + } + elseif (Test-Path -LiteralPath $source) { + Write-Host ("{0,-14} local directory" -f $name) + } + else { + Write-Host ("{0,-14} missing" -f $name) + } + } +} + +$sourceRoot = Resolve-SourceRoot $SourcePath +$vaultRoot = Resolve-VaultRoot $VaultPath +Initialize-VaultRepository $vaultRoot + +if ($Action -eq "register") { + $id = Get-RepositoryId -SourceRoot $sourceRoot -RequestedId $RepositoryId + Register-Repository -SourceRoot $sourceRoot -VaultRoot $vaultRoot -Id $id +} +else { + $id = Find-RegistrationId -SourceRoot $sourceRoot -VaultRoot $vaultRoot -RequestedId $RepositoryId + switch ($Action) { + "relink" { Relink-Repository -SourceRoot $sourceRoot -VaultRoot $vaultRoot -Id $id } + "unregister" { Unregister-Repository -SourceRoot $sourceRoot -VaultRoot $vaultRoot -Id $id } + "status" { Show-Status -SourceRoot $sourceRoot -VaultRoot $vaultRoot -Id $id } + } +} diff --git a/scripts/register-vault.sh b/scripts/register-vault.sh new file mode 100644 index 0000000..09cbc0f --- /dev/null +++ b/scripts/register-vault.sh @@ -0,0 +1,390 @@ +#!/usr/bin/env sh +set -eu + +ACTION="${1:-register}" +if [ "$#" -gt 0 ]; then shift; fi + +SOURCE_PATH="$(pwd)" +VAULT_PATH="${CODEBASE_LEARNING_VAULT:-}" +REPOSITORY_ID="" +RESTORE="false" +STATE_DIRECTORIES=".local learning-flow agentic-flow" +EXCLUDE_START="# codebase-learning-flow-vault:start" +EXCLUDE_END="# codebase-learning-flow-vault:end" + +usage() { + cat <<'EOF' +Usage: register-vault.sh register|unregister|relink|status [options] + +Options: + --source PATH Source Git repository (default: current directory) + --vault-path PATH LearningVault root (default: $HOME/LearningVault) + --repository-id ID Explicit registration ID, primarily for relinking + --restore Required by unregister; moves state back to the source + -h, --help Show this help + +The script never creates a remote, stages files, or commits. +EOF +} + +log() { + printf '%s\n' "[learning-vault] $*" +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --source) [ "$#" -ge 2 ] || { echo "--source requires a value." >&2; exit 2; }; SOURCE_PATH="$2"; shift 2 ;; + --vault-path) [ "$#" -ge 2 ] || { echo "--vault-path requires a value." >&2; exit 2; }; VAULT_PATH="$2"; shift 2 ;; + --repository-id) [ "$#" -ge 2 ] || { echo "--repository-id requires a value." >&2; exit 2; }; REPOSITORY_ID="$2"; shift 2 ;; + --restore) RESTORE="true"; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +case "$ACTION" in register|unregister|relink|status) ;; *) echo "Unknown action: $ACTION" >&2; usage >&2; exit 2 ;; esac + +command -v git >/dev/null 2>&1 || { echo "Git is required to register a LearningVault repository." >&2; exit 1; } +[ -d "$SOURCE_PATH" ] || { echo "Source repository does not exist: $SOURCE_PATH" >&2; exit 1; } +SOURCE_PATH="$(cd "$SOURCE_PATH" && pwd -P)" +SOURCE_ROOT="$(git -C "$SOURCE_PATH" rev-parse --show-toplevel 2>/dev/null)" || { + echo "Source path is not inside a Git repository: $SOURCE_PATH" >&2 + exit 1 +} +SOURCE_ROOT="$(cd "$SOURCE_ROOT" && pwd -P)" +[ "$SOURCE_PATH" = "$SOURCE_ROOT" ] || { + echo "Run registration at the repository root ($SOURCE_ROOT), or pass --source $SOURCE_ROOT." >&2 + exit 1 +} + +if [ -z "$VAULT_PATH" ]; then + [ -n "${HOME:-}" ] || { echo "Cannot resolve LearningVault: pass --vault-path or set CODEBASE_LEARNING_VAULT." >&2; exit 1; } + VAULT_PATH="$HOME/LearningVault" +fi +mkdir -p "$VAULT_PATH" +VAULT_ROOT="$(cd "$VAULT_PATH" && pwd -P)" + +if [ "$(git -C "$VAULT_ROOT" rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]; then + git -C "$VAULT_ROOT" init >/dev/null + log "Initialized local Git repository at $VAULT_ROOT" +fi +if [ -n "$(git -C "$VAULT_ROOT" remote 2>/dev/null || true)" ]; then + log "WARNING: this LearningVault has a Git remote. Registration will not modify it." +fi +mkdir -p "$VAULT_ROOT/repositories" + +absolute_path() { + path="$1" + parent="$(dirname "$path")" + base="$(basename "$path")" + if [ -d "$path" ]; then + (cd "$path" && pwd -P) + else + printf '%s/%s\n' "$(cd "$parent" && pwd -P)" "$base" + fi +} + +same_path() { + [ "$(absolute_path "$1")" = "$(absolute_path "$2")" ] +} + +link_target() { + link="$1" + target="$(readlink "$link")" + case "$target" in + /*) absolute_path "$target" ;; + *) absolute_path "$(dirname "$link")/$target" ;; + esac +} + +test_link_capability() { + probe_target="$VAULT_ROOT/.link-target-$$" + probe_link="$VAULT_ROOT/.link-probe-$$" + mkdir "$probe_target" + if ! ln -s "$probe_target" "$probe_link" 2>/dev/null; then + rmdir "$probe_target" + echo "Cannot create LearningVault symbolic links at $VAULT_ROOT. Verify filesystem support and permissions." >&2 + exit 1 + fi + [ -L "$probe_link" ] || { rm -f "$probe_link"; rmdir "$probe_target"; echo "The platform created no usable symbolic link." >&2; exit 1; } + rm -f "$probe_link" + rmdir "$probe_target" +} + +sha256_prefix() { + value="$1" + if command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$value" | sha256sum | awk '{print substr($1,1,8)}' + elif command -v shasum >/dev/null 2>&1; then + printf '%s' "$value" | shasum -a 256 | awk '{print substr($1,1,8)}' + elif command -v openssl >/dev/null 2>&1; then + printf '%s' "$value" | openssl dgst -sha256 | awk '{print substr($NF,1,8)}' + else + echo "Registration requires sha256sum, shasum, or openssl for stable repository identity." >&2 + exit 1 + fi +} + +safe_id() { + printf '%s' "$1" | + tr '[:upper:]' '[:lower:]' | + sed 's/\.git$//; s/[^a-z0-9._-][^a-z0-9._-]*/-/g; s/^[-._]*//; s/[-._]*$//' +} + +repository_identity() { + origin="$(git -C "$SOURCE_ROOT" config --get remote.origin.url 2>/dev/null || true)" + if [ -n "$origin" ]; then + identity="$(printf '%s' "$origin" | tr '[:upper:]' '[:lower:]')" + name="$(basename "${origin%/}")" + name="$(safe_id "$name")" + else + identity="$(printf '%s' "$SOURCE_ROOT" | tr '[:upper:]' '[:lower:]')" + name="$(safe_id "$(basename "$SOURCE_ROOT")")" + fi + [ -n "$name" ] || name="repository" + printf '%s|%s|%s\n' "$name" "$identity" "$origin" +} + +get_repository_id() { + if [ -n "$REPOSITORY_ID" ]; then + normalized="$(safe_id "$REPOSITORY_ID")" + [ "$normalized" = "$(printf '%s' "$REPOSITORY_ID" | tr '[:upper:]' '[:lower:]')" ] || { + echo "Repository ID contains unsupported characters: $REPOSITORY_ID" >&2 + exit 1 + } + printf '%s\n' "$normalized" + return + fi + identity_record="$(repository_identity)" + name="${identity_record%%|*}" + remainder="${identity_record#*|}" + identity="${remainder%%|*}" + printf '%s-%s\n' "$name" "$(sha256_prefix "$identity")" +} + +exclude_path() { + path="$(git -C "$SOURCE_ROOT" rev-parse --path-format=absolute --git-path info/exclude 2>/dev/null || true)" + if [ -z "$path" ]; then + path="$(git -C "$SOURCE_ROOT" rev-parse --git-path info/exclude)" + case "$path" in /*) ;; *) path="$SOURCE_ROOT/$path" ;; esac + fi + printf '%s\n' "$path" +} + +set_exclude_block() { + present="$1" + path="$(exclude_path)" + mkdir -p "$(dirname "$path")" + [ -f "$path" ] || : > "$path" + temp="$path.learning-vault.$$" + awk -v start="$EXCLUDE_START" -v end="$EXCLUDE_END" ' + $0 == start { skipping = 1; next } + $0 == end { skipping = 0; next } + !skipping { print } + ' "$path" > "$temp" + if [ "$present" = "true" ]; then + if [ -s "$temp" ]; then printf '\n' >> "$temp"; fi + { + printf '%s\n' "$EXCLUDE_START" + printf '%s\n' '/.local/' '/learning-flow/' '/agentic-flow/' + printf '%s\n' "$EXCLUDE_END" + } >> "$temp" + fi + mv "$temp" "$path" +} + +assert_linked_install() { + marker="$SOURCE_ROOT/learning-flow/.install-scope" + [ -f "$marker" ] || { + echo "LearningVault registration requires an existing linked installation. Run the installer with --scope linked first." >&2 + exit 1 + } + scope="$(sed -n 's/^scope:[[:space:]]*//p' "$marker" | sed -n '1p')" + [ "$scope" = "linked" ] || { + echo "LearningVault registration supports linked scope only. Convert with --scope linked --mode update first." >&2 + exit 1 + } +} + +assert_state_untracked() { + tracked="" + for name in $STATE_DIRECTORIES; do + found="$(git -C "$SOURCE_ROOT" ls-files -- "$name")" + [ -z "$found" ] || tracked="${tracked}${tracked:+, }$found" + done + [ -z "$tracked" ] || { + echo "Refusing to vault tracked paths. Untrack or commit a deliberate repository migration first: $tracked" >&2 + exit 1 + } +} + +write_metadata() { + registration="$1" + id="$2" + identity_record="$(repository_identity)" + origin="${identity_record##*|}" + [ -n "$origin" ] || origin="(none)" + cat > "$registration/VAULT.md" <&2 + exit 1 +} + +register_repository() { + id="$1" + assert_linked_install + assert_state_untracked + test_link_capability + registration="$VAULT_ROOT/repositories/$id" + mkdir -p "$registration" + + for name in $STATE_DIRECTORIES; do + source="$SOURCE_ROOT/$name" + destination="$registration/$name" + if [ -L "$source" ]; then + same_path "$(link_target "$source")" "$destination" || { + echo "$source links to a different location. Use relink or unregister it first." >&2 + exit 1 + } + elif [ -e "$source" ] && [ -e "$destination" ]; then + echo "Both source and vault copies exist for $name. Refusing to merge them." >&2 + exit 1 + fi + done + + moved="" + linked="" + rollback() { + for name in $linked; do [ -L "$SOURCE_ROOT/$name" ] && rm -f "$SOURCE_ROOT/$name"; done + for name in $moved; do + [ -e "$registration/$name" ] && [ ! -e "$SOURCE_ROOT/$name" ] && mv "$registration/$name" "$SOURCE_ROOT/$name" + done + } + trap 'rollback' HUP INT TERM + + for name in $STATE_DIRECTORIES; do + source="$SOURCE_ROOT/$name" + destination="$registration/$name" + if [ -L "$source" ]; then continue; fi + if [ -e "$source" ]; then + mv "$source" "$destination" + moved="$name $moved" + elif [ ! -e "$destination" ]; then + mkdir -p "$destination" + fi + if ! ln -s "$destination" "$source"; then + rollback + trap - HUP INT TERM + echo "Failed to link $source; moved directories were restored." >&2 + exit 1 + fi + linked="$name $linked" + done + trap - HUP INT TERM + set_exclude_block true + write_metadata "$registration" "$id" + log "Registered $SOURCE_ROOT as $id" +} + +relink_repository() { + id="$1" + test_link_capability + registration="$VAULT_ROOT/repositories/$id" + [ -d "$registration" ] || { echo "Vault registration does not exist: $registration" >&2; exit 1; } + for name in $STATE_DIRECTORIES; do + [ -d "$registration/$name" ] || { echo "Vault registration is missing $name." >&2; exit 1; } + source="$SOURCE_ROOT/$name" + [ -L "$source" ] || [ ! -e "$source" ] || { echo "Cannot relink because a real source directory exists: $source" >&2; exit 1; } + done + for name in $STATE_DIRECTORIES; do + source="$SOURCE_ROOT/$name" + [ ! -L "$source" ] || rm -f "$source" + ln -s "$registration/$name" "$source" + done + set_exclude_block true + write_metadata "$registration" "$id" + log "Relinked $id to $SOURCE_ROOT" +} + +unregister_repository() { + id="$1" + [ "$RESTORE" = "true" ] || { echo "Unregister requires --restore so the source never loses its only working state." >&2; exit 1; } + registration="$VAULT_ROOT/repositories/$id" + for name in $STATE_DIRECTORIES; do + source="$SOURCE_ROOT/$name" + [ -L "$source" ] || [ ! -e "$source" ] || { echo "Cannot restore because a real source directory exists: $source" >&2; exit 1; } + [ -d "$registration/$name" ] || { echo "Cannot restore because the vault copy is missing: $registration/$name" >&2; exit 1; } + done + for name in $STATE_DIRECTORIES; do + source="$SOURCE_ROOT/$name" + [ ! -L "$source" ] || rm -f "$source" + mv "$registration/$name" "$source" + done + set_exclude_block false + rm -f "$registration/VAULT.md" + rmdir "$registration" 2>/dev/null || true + log "Restored $id to $SOURCE_ROOT" +} + +show_status() { + id="$1" + printf 'LearningVault: %s\nRepository: %s\nRegistration: %s\n' "$VAULT_ROOT" "$SOURCE_ROOT" "$id" + for name in $STATE_DIRECTORIES; do + source="$SOURCE_ROOT/$name" + if [ -L "$source" ]; then + printf '%-14s linked -> %s\n' "$name" "$(link_target "$source")" + elif [ -d "$source" ]; then + printf '%-14s local directory\n' "$name" + else + printf '%-14s missing\n' "$name" + fi + done +} + +if [ "$ACTION" = "register" ]; then + ID="$(get_repository_id)" +else + ID="$(find_registration_id)" +fi + +case "$ACTION" in + register) register_repository "$ID" ;; + unregister) unregister_repository "$ID" ;; + relink) relink_repository "$ID" ;; + status) show_status "$ID" ;; +esac From 6bcc834b0edc5533e6ed499313a87d927c15e0c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Gell=C3=A9r?= Date: Wed, 26 Aug 2026 10:57:34 +0200 Subject: [PATCH 2/7] feat: integrate LearningVault with linked installs Seed a local vault from the installer and optionally register linked repositories without modifying their shared gitignore. Co-authored-by: Cursor --- MANIFEST.txt | 4 +- scripts/install.ps1 | 155 ++++++++++++++++++++++++++++++++++++++------ scripts/install.sh | 97 ++++++++++++++++++++++++++- 3 files changed, 232 insertions(+), 24 deletions(-) diff --git a/MANIFEST.txt b/MANIFEST.txt index 69589fa..f0d5c15 100644 --- a/MANIFEST.txt +++ b/MANIFEST.txt @@ -99,8 +99,8 @@ sample/vault/AGENTS.md 1580 sample/vault/README.md 1369 scripts/README.md 10227 scripts/install.bat 1477 -scripts/install.ps1 55583 -scripts/install.sh 47145 +scripts/install.ps1 60821 +scripts/install.sh 51063 scripts/register-vault.ps1 18800 scripts/register-vault.sh 13633 skill-evals/README.md 1689 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 02fa2fc..d1affbf 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -28,6 +28,11 @@ param( [string]$RootAgents = "Auto", [switch]$SkipRootAgents, [switch]$SkipSkills, + # Optional local-only LearningVault storage. -VaultInit seeds the vault; + # -VaultRegister also registers this repository after a linked install. + [switch]$VaultInit, + [switch]$VaultRegister, + [string]$VaultPath = "", [switch]$SkipSelfRefresh ) @@ -46,6 +51,12 @@ if (-not [string]::IsNullOrWhiteSpace($Release)) { if (-not [string]::IsNullOrWhiteSpace($PackageFile)) { $SkipSelfRefresh = $true } +if ($VaultRegister -and $Scope -ne "Linked") { + throw "-VaultRegister requires -Scope Linked so framework files remain owned by the global installation." +} +if (-not [string]::IsNullOrWhiteSpace($VaultPath) -and -not $VaultInit -and -not $VaultRegister) { + throw "-VaultPath requires -VaultInit or -VaultRegister." +} function Write-Step([string]$Message) { Write-Host "[learning-flow] $Message" @@ -63,6 +74,21 @@ function Resolve-GlobalRoot { return (Join-Path $home_directory ".agents") } +function Resolve-LearningVaultRoot([string]$RequestedPath) { + if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) { + return [System.IO.Path]::GetFullPath($RequestedPath) + } + if (-not [string]::IsNullOrWhiteSpace($env:CODEBASE_LEARNING_VAULT)) { + return [System.IO.Path]::GetFullPath($env:CODEBASE_LEARNING_VAULT) + } + $homeDirectory = $env:USERPROFILE + if ([string]::IsNullOrWhiteSpace($homeDirectory)) { $homeDirectory = $env:HOME } + if ([string]::IsNullOrWhiteSpace($homeDirectory)) { + throw "Cannot resolve LearningVault: pass -VaultPath or set CODEBASE_LEARNING_VAULT." + } + return Join-Path $homeDirectory "LearningVault" +} + function Read-MarkerField([string]$MarkerPath, [string]$Field) { if (-not (Test-Path -LiteralPath $MarkerPath -PathType Leaf)) { return "" } foreach ($line in Get-Content -LiteralPath $MarkerPath) { @@ -110,7 +136,11 @@ function Confirm-Checksum([string]$Path, [string]$ChecksumsPath, [string]$AssetN } } -function Initialize-LocalLearningWorkspace([string]$TargetRoot, [string]$HistoryTemplate) { +function Initialize-LocalLearningWorkspace( + [string]$TargetRoot, + [string]$HistoryTemplate, + [switch]$SkipGitIgnore +) { if (-not (Test-Path -LiteralPath $HistoryTemplate -PathType Leaf)) { throw "Local learning-history template is missing: $HistoryTemplate" } @@ -121,27 +151,29 @@ function Initialize-LocalLearningWorkspace([string]$TargetRoot, [string]$History throw "$ignorePath exists but is not a file." } - $hasLocalIgnore = $false - if (Test-Path -LiteralPath $ignorePath -PathType Leaf) { - $hasLocalIgnore = $null -ne ( - Get-Content -LiteralPath $ignorePath | - Where-Object { $_.Trim() -in @("/.local/", ".local/", "/.local", ".local") } | - Select-Object -First 1 - ) - } - if (-not $hasLocalIgnore) { - $newline = "`n" + if (-not $SkipGitIgnore) { + $hasLocalIgnore = $false if (Test-Path -LiteralPath $ignorePath -PathType Leaf) { - $content = [System.IO.File]::ReadAllText($ignorePath) - if ($content.Contains("`r`n")) { $newline = "`r`n" } - $entry = "/.local/$newline" - if ($content.Length -gt 0 -and -not $content.EndsWith("`n")) { $entry = "$newline$entry" } - [System.IO.File]::AppendAllText($ignorePath, $entry, [System.Text.UTF8Encoding]::new($false)) + $hasLocalIgnore = $null -ne ( + Get-Content -LiteralPath $ignorePath | + Where-Object { $_.Trim() -in @("/.local/", ".local/", "/.local", ".local") } | + Select-Object -First 1 + ) } - else { - [System.IO.File]::WriteAllText($ignorePath, "/.local/$newline", [System.Text.UTF8Encoding]::new($false)) + if (-not $hasLocalIgnore) { + $newline = "`n" + if (Test-Path -LiteralPath $ignorePath -PathType Leaf) { + $content = [System.IO.File]::ReadAllText($ignorePath) + if ($content.Contains("`r`n")) { $newline = "`r`n" } + $entry = "/.local/$newline" + if ($content.Length -gt 0 -and -not $content.EndsWith("`n")) { $entry = "$newline$entry" } + [System.IO.File]::AppendAllText($ignorePath, $entry, [System.Text.UTF8Encoding]::new($false)) + } + else { + [System.IO.File]::WriteAllText($ignorePath, "/.local/$newline", [System.Text.UTF8Encoding]::new($false)) + } + $changed = $true } - $changed = $true } $localRoot = Join-Path $TargetRoot ".local" @@ -167,6 +199,38 @@ function Initialize-LocalLearningWorkspace([string]$TargetRoot, [string]$History if ($changed) { Write-Step "Initialized private learning state under .local/" } } +function Initialize-LearningVault( + [string]$Root, + [string]$TemplateRoot, + [string]$PowerShellRegistrationScript, + [string]$ShellRegistrationScript +) { + if ($null -eq (Get-Command git -ErrorAction SilentlyContinue)) { + throw "Git is required to initialize LearningVault." + } + New-Item -ItemType Directory -Path $Root -Force | Out-Null + $inside = @(& git -C $Root rev-parse --is-inside-work-tree 2>$null) + if ($LASTEXITCODE -ne 0 -or ($inside | Select-Object -First 1) -ne "true") { + & git -C $Root init | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to initialize LearningVault at $Root." } + Write-Step "Initialized local LearningVault Git repository at $Root" + } + New-Item -ItemType Directory -Path (Join-Path $Root "repositories"), (Join-Path $Root "scripts") -Force | Out-Null + foreach ($name in @("README.md", "AGENTS.md", ".gitignore")) { + $source = Join-Path $TemplateRoot $name + $target = Join-Path $Root $name + if (-not (Test-Path -LiteralPath $target)) { + Copy-Item -LiteralPath $source -Destination $target + } + } + Copy-Item -LiteralPath $PowerShellRegistrationScript -Destination (Join-Path $Root "scripts/register-vault.ps1") -Force + Copy-Item -LiteralPath $ShellRegistrationScript -Destination (Join-Path $Root "scripts/register-vault.sh") -Force + if (@(& git -C $Root remote 2>$null).Count -gt 0) { + Write-Step "WARNING: LearningVault already has a Git remote. The installer did not modify it." + } + Write-Step "LearningVault ready at $Root" +} + function Resolve-RemoteCommit([string]$RepositoryName, [string]$RequestedRef) { if ($RequestedRef -match "^[0-9a-fA-F]{40}$") { return $RequestedRef.ToLowerInvariant() @@ -212,6 +276,14 @@ function Test-DirectoryHasContent([string]$Path) { return $null -ne (Get-ChildItem -LiteralPath $Path -Force | Select-Object -First 1) } +function Test-DirectoryLink([string]$Path) { + try { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + return ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + } + catch { return $false } +} + function Get-InstalledProfile([string]$LearningPath) { $profileFile = Join-Path $LearningPath ".template-profile" if (Test-Path -LiteralPath $profileFile -PathType Leaf) { @@ -690,6 +762,9 @@ if (-not $SkipSelfRefresh) { -RootAgents $RootAgents ` -SkipRootAgents:$($SkipRootAgents.IsPresent) ` -SkipSkills:$($SkipSkills.IsPresent) ` + -VaultInit:$($VaultInit.IsPresent) ` + -VaultRegister:$($VaultRegister.IsPresent) ` + -VaultPath $VaultPath ` -SkipSelfRefresh } else { @@ -705,6 +780,9 @@ if (-not $SkipSelfRefresh) { -RootAgents $RootAgents ` -SkipRootAgents:$($SkipRootAgents.IsPresent) ` -SkipSkills:$($SkipSkills.IsPresent) ` + -VaultInit:$($VaultInit.IsPresent) ` + -VaultRegister:$($VaultRegister.IsPresent) ` + -VaultPath $VaultPath ` -SkipSelfRefresh } return @@ -754,6 +832,9 @@ if (-not [string]::IsNullOrWhiteSpace($installedScope) -and $installedScope -ne Write-Step "Converting repository-scoped installation to linked; framework files move to $globalRoot" } elseif ($installedScope -eq "linked" -and $scopeName -eq "repository") { + if ((Test-DirectoryLink $targetAgentic) -or (Test-DirectoryLink $targetLearning) -or (Test-DirectoryLink (Join-Path $resolvedTarget ".local"))) { + throw "This linked installation uses LearningVault directory links. Run register-vault.ps1 unregister -Restore before converting it to repository scope." + } if ($Mode -notin @("Merge", "Update", "Replace")) { throw "Scope change linked -> repository is not supported in mode '$Mode'. Use Merge, Update, or Replace." } @@ -910,6 +991,9 @@ try { $sourceLearningRepositoryFiles = Join-Path $sourceLearning ".repository-files" $sourceRootAgents = Join-Path $archiveRoot "sample/root/AGENTS.md" $sourceRootPointer = Join-Path $archiveRoot "sample/root/AGENTS.pointer.md" + $sourceVault = Join-Path $archiveRoot "sample/vault" + $sourceVaultPowerShell = Join-Path $archiveRoot "scripts/register-vault.ps1" + $sourceVaultShell = Join-Path $archiveRoot "scripts/register-vault.sh" $sourceExtension = Join-Path $archiveRoot "sample/extensions/regulatory" $sourceExtensionLearning = Join-Path $sourceExtension "learning-flow" @@ -927,6 +1011,19 @@ try { throw "Required framework manifest is missing: $requiredFile" } } + if ($VaultInit -or $VaultRegister) { + foreach ($requiredFile in @( + (Join-Path $sourceVault "README.md"), + (Join-Path $sourceVault "AGENTS.md"), + (Join-Path $sourceVault ".gitignore"), + $sourceVaultPowerShell, + $sourceVaultShell + )) { + if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) { + throw "Required LearningVault file is missing: $requiredFile" + } + } + } if ($scopeName -eq "global") { $SkipRootAgents = [switch]$true $RootAgents = "Skip" @@ -1006,7 +1103,10 @@ try { } if ($scopeName -ne "global") { - Initialize-LocalLearningWorkspace -TargetRoot $resolvedTarget -HistoryTemplate $sourceLocalHistory + Initialize-LocalLearningWorkspace ` + -TargetRoot $resolvedTarget ` + -HistoryTemplate $sourceLocalHistory ` + -SkipGitIgnore:$($VaultRegister.IsPresent) } if ($scopeName -eq "linked" -and $installedScope -eq "repository" -and -not $SkipSkills) { @@ -1085,6 +1185,21 @@ try { -VersionValue $frameworkVersion ` -GlobalVersionValue $(if ($scopeName -eq "linked") { $globalVersion } else { "" }) + if ($VaultInit -or $VaultRegister) { + $resolvedVault = Resolve-LearningVaultRoot $VaultPath + Initialize-LearningVault ` + -Root $resolvedVault ` + -TemplateRoot $sourceVault ` + -PowerShellRegistrationScript $sourceVaultPowerShell ` + -ShellRegistrationScript $sourceVaultShell + if ($VaultRegister) { + & (Join-Path $resolvedVault "scripts/register-vault.ps1") ` + register ` + -SourcePath $resolvedTarget ` + -VaultPath $resolvedVault + } + } + if ($scopeName -eq "linked" -and -not [string]::IsNullOrWhiteSpace($globalVersion) -and $globalVersion -ne $frameworkVersion) { Write-Step "WARNING: this repository was linked at $frameworkVersion but $globalRoot holds $globalVersion. Reinstall one of them so the routing contract and the repository state agree." } diff --git a/scripts/install.sh b/scripts/install.sh index 040d285..b8e1c14 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -14,6 +14,9 @@ EXTENSION="auto" SKIP_ROOT_AGENTS="false" ROOT_AGENTS_MODE="auto" SKIP_SKILLS="false" +VAULT_INIT="false" +VAULT_REGISTER="false" +VAULT_PATH="" usage() { cat <<'EOF' @@ -39,10 +42,14 @@ Options: --root-agents MODE auto|integrate|initialize|preserve|skip --skip-root-agents Alias for --root-agents skip --skip-skills Do not install or update managed skills + --vault-init Initialize or refresh a local-only LearningVault + --vault-register Register this linked repository after installation + --vault-path PATH Override $HOME/LearningVault -h, --help Show this help --ref and --release are mutually exclusive. The global root can be overridden with CODEBASE_LEARNING_FLOW_HOME. +The vault root can be overridden with CODEBASE_LEARNING_VAULT. EOF } @@ -64,6 +71,22 @@ resolve_global_root() { printf '%s\n' "$home_directory/.agents" } +resolve_learning_vault_root() { + if [ -n "$VAULT_PATH" ]; then + printf '%s\n' "$VAULT_PATH" + return + fi + if [ -n "${CODEBASE_LEARNING_VAULT:-}" ]; then + printf '%s\n' "$CODEBASE_LEARNING_VAULT" + return + fi + [ -n "${HOME:-}" ] || { + echo "Cannot resolve LearningVault: pass --vault-path or set CODEBASE_LEARNING_VAULT." >&2 + exit 1 + } + printf '%s\n' "$HOME/LearningVault" +} + read_marker_field() { marker="$1" field="$2" @@ -88,6 +111,7 @@ write_install_scope_marker() { initialize_local_learning_workspace() { target_root="$1" history_template="$2" + skip_gitignore="${3:-false}" ignore_path="$target_root/.gitignore" local_root="$target_root/.local" changed="false" @@ -97,7 +121,7 @@ initialize_local_learning_workspace() { echo "$ignore_path exists but is not a file." >&2 exit 1 fi - if [ ! -f "$ignore_path" ] || ! grep -Eq '^[[:space:]]*/?\.local/?[[:space:]]*$' "$ignore_path"; then + if [ "$skip_gitignore" != "true" ] && { [ ! -f "$ignore_path" ] || ! grep -Eq '^[[:space:]]*/?\.local/?[[:space:]]*$' "$ignore_path"; }; then if [ -s "$ignore_path" ]; then printf '\n/.local/\n' >> "$ignore_path"; else printf '/.local/\n' > "$ignore_path"; fi changed="true" fi @@ -120,6 +144,31 @@ initialize_local_learning_workspace() { if [ "$changed" = "true" ]; then log "Initialized private learning state under .local/"; fi } +initialize_learning_vault() { + vault_root="$1" + template_root="$2" + powershell_script="$3" + shell_script="$4" + + command -v git >/dev/null 2>&1 || { echo "Git is required to initialize LearningVault." >&2; exit 1; } + mkdir -p "$vault_root" + if [ "$(git -C "$vault_root" rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]; then + git -C "$vault_root" init >/dev/null + log "Initialized local LearningVault Git repository at $vault_root" + fi + mkdir -p "$vault_root/repositories" "$vault_root/scripts" + for name in README.md AGENTS.md .gitignore; do + [ -e "$vault_root/$name" ] || cp "$template_root/$name" "$vault_root/$name" + done + cp "$powershell_script" "$vault_root/scripts/register-vault.ps1" + cp "$shell_script" "$vault_root/scripts/register-vault.sh" + chmod +x "$vault_root/scripts/register-vault.sh" 2>/dev/null || true + if [ -n "$(git -C "$vault_root" remote 2>/dev/null || true)" ]; then + log "WARNING: LearningVault already has a Git remote. The installer did not modify it." + fi + log "LearningVault ready at $vault_root" +} + require_value() { option="$1" remaining="$2" @@ -784,6 +833,20 @@ while [ "$#" -gt 0 ]; do SKIP_SKILLS="true" shift ;; + --vault-init) + VAULT_INIT="true" + shift + ;; + --vault-register) + VAULT_REGISTER="true" + VAULT_INIT="true" + shift + ;; + --vault-path) + require_value "$1" "$#" + VAULT_PATH="$2" + shift 2 + ;; -h|--help) usage exit 0 @@ -801,6 +864,14 @@ case "$SCOPE" in repository|global|linked) ;; *) echo "Invalid scope: $SCOPE" >& case "$PROFILE" in auto|minimal|full) ;; *) echo "Invalid profile: $PROFILE" >&2; exit 2 ;; esac case "$EXTENSION" in auto|none|regulatory) ;; *) echo "Invalid extension: $EXTENSION" >&2; exit 2 ;; esac case "$ROOT_AGENTS_MODE" in auto|integrate|initialize|preserve|skip) ;; *) echo "Invalid root agents mode: $ROOT_AGENTS_MODE" >&2; exit 2 ;; esac +[ "$VAULT_REGISTER" != "true" ] || [ "$SCOPE" = "linked" ] || { + echo "--vault-register requires --scope linked so framework files remain owned by the global installation." >&2 + exit 2 +} +[ -z "$VAULT_PATH" ] || [ "$VAULT_INIT" = "true" ] || { + echo "--vault-path requires --vault-init or --vault-register." >&2 + exit 2 +} command -v unzip >/dev/null 2>&1 || { echo "The installer requires unzip." >&2; exit 1; } @@ -840,6 +911,10 @@ if [ -n "$INSTALLED_SCOPE" ] && [ "$INSTALLED_SCOPE" != "$SCOPE" ]; then esac ;; linked/repository) + if [ -L "$TARGET_AGENTIC" ] || [ -L "$TARGET_LEARNING" ] || [ -L "$TARGET_PATH/.local" ]; then + echo "This linked installation uses LearningVault directory links. Run register-vault.sh unregister --restore before converting it to repository scope." >&2 + exit 1 + fi case "$MODE" in merge|update|replace) log "Converting linked installation to a self-contained repository installation" ;; *) echo "Scope change linked -> repository is not supported in mode '$MODE'. Use merge, update, or replace." >&2; exit 1 ;; @@ -1007,6 +1082,9 @@ SOURCE_AGENTIC_REPOSITORY_FILES="$SOURCE_AGENTIC/.repository-files" SOURCE_LEARNING_REPOSITORY_FILES="$SOURCE_LEARNING/.repository-files" SOURCE_ROOT_AGENTS="$ARCHIVE_ROOT/sample/root/AGENTS.md" SOURCE_ROOT_POINTER="$ARCHIVE_ROOT/sample/root/AGENTS.pointer.md" +SOURCE_VAULT="$ARCHIVE_ROOT/sample/vault" +SOURCE_VAULT_POWERSHELL="$ARCHIVE_ROOT/scripts/register-vault.ps1" +SOURCE_VAULT_SHELL="$ARCHIVE_ROOT/scripts/register-vault.sh" SOURCE_EXTENSION="$ARCHIVE_ROOT/sample/extensions/regulatory" SOURCE_EXTENSION_LEARNING="$SOURCE_EXTENSION/learning-flow" SOURCE_EXTENSION_SKILLS="$SOURCE_EXTENSION/.agents/skills" @@ -1019,6 +1097,11 @@ done for required in "$SOURCE_AGENTIC_MANAGED_FILES" "$SOURCE_AGENTIC_MANAGED_SKILLS" "$SOURCE_LEARNING_MANAGED_FILES" "$SOURCE_LEARNING_MANAGED_SKILLS" "$SOURCE_AGENTIC_REPOSITORY_FILES" "$SOURCE_LEARNING_REPOSITORY_FILES" "$SOURCE_LOCAL_HISTORY"; do [ -f "$required" ] || { echo "Required framework manifest is missing: $required" >&2; exit 1; } done +if [ "$VAULT_INIT" = "true" ]; then + for required in "$SOURCE_VAULT/README.md" "$SOURCE_VAULT/AGENTS.md" "$SOURCE_VAULT/.gitignore" "$SOURCE_VAULT_POWERSHELL" "$SOURCE_VAULT_SHELL"; do + [ -f "$required" ] || { echo "Required LearningVault file is missing: $required" >&2; exit 1; } + done +fi if [ "$SCOPE" = "global" ]; then SKIP_ROOT_AGENTS="true" ROOT_AGENTS_MODE="skip" @@ -1104,7 +1187,7 @@ if [ "$SCOPE" != "linked" ]; then fi if [ "$SCOPE" != "global" ]; then - initialize_local_learning_workspace "$TARGET_PATH" "$SOURCE_LOCAL_HISTORY" + initialize_local_learning_workspace "$TARGET_PATH" "$SOURCE_LOCAL_HISTORY" "$VAULT_REGISTER" fi if [ "$SCOPE" = "linked" ] && [ "$INSTALLED_SCOPE" = "repository" ] && [ "$SKIP_SKILLS" != "true" ]; then @@ -1181,6 +1264,16 @@ fi write_install_scope_marker "$TARGET_LEARNING/.install-scope" "$SCOPE" "$FRAMEWORK_VERSION" "$GLOBAL_VERSION" +if [ "$VAULT_INIT" = "true" ]; then + RESOLVED_VAULT="$(resolve_learning_vault_root)" + mkdir -p "$RESOLVED_VAULT" + RESOLVED_VAULT="$(cd "$RESOLVED_VAULT" && pwd)" + initialize_learning_vault "$RESOLVED_VAULT" "$SOURCE_VAULT" "$SOURCE_VAULT_POWERSHELL" "$SOURCE_VAULT_SHELL" + if [ "$VAULT_REGISTER" = "true" ]; then + sh "$RESOLVED_VAULT/scripts/register-vault.sh" register --source "$TARGET_PATH" --vault-path "$RESOLVED_VAULT" + fi +fi + if [ "$SCOPE" = "linked" ] && [ -n "$GLOBAL_VERSION" ] && [ "$GLOBAL_VERSION" != "$FRAMEWORK_VERSION" ]; then log "WARNING: this repository was linked at $FRAMEWORK_VERSION but $GLOBAL_ROOT holds $GLOBAL_VERSION. Reinstall one of them so the routing contract and the repository state agree." fi From aa0b3060d25816b73a041cec09248c7af23f7847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Gell=C3=A9r?= Date: Wed, 26 Aug 2026 11:00:25 +0200 Subject: [PATCH 3/7] test: cover LearningVault lifecycle across platforms Exercise registration, relocation, idempotent updates, restoration, tracked-path refusal, and packaged release completeness. Co-authored-by: Cursor --- .github/workflows/ci.yml | 19 +++-- scripts/ci-install-test.sh | 71 ++++++++++++++++++- scripts/ci-release-test.sh | 25 ++++++- scripts/ci-vault-test.ps1 | 140 +++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 scripts/ci-vault-test.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80c59fd..3f42f03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: Validate repository structure run: python scripts/ci-validate.py - name: Validate POSIX installer syntax - run: sh -n scripts/install.sh + run: sh -n scripts/install.sh scripts/register-vault.sh - name: Run installer smoke test run: bash scripts/ci-install-test.sh @@ -34,9 +34,17 @@ jobs: - name: Validate PowerShell syntax shell: pwsh run: | - $errors = $null - [void][System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path "scripts/install.ps1"), [ref]$null, [ref]$errors) + $errors = @() + foreach ($path in @( + "scripts/install.ps1", + "scripts/register-vault.ps1", + "scripts/ci-vault-test.ps1" + )) { + $fileErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path $path), [ref]$null, [ref]$fileErrors) + $errors += $fileErrors + } if ($errors.Count -gt 0) { $errors | Format-List; exit 1 } - name: Run installer smoke test shell: pwsh @@ -88,3 +96,6 @@ jobs: if (Test-Path "$linked/agentic-flow/AGENTS.md") { throw "Framework instructions were duplicated into the linked repository." } + - name: Run LearningVault lifecycle test + shell: pwsh + run: ./scripts/ci-vault-test.ps1 diff --git a/scripts/ci-install-test.sh b/scripts/ci-install-test.sh index 9cea196..f61b1a0 100755 --- a/scripts/ci-install-test.sh +++ b/scripts/ci-install-test.sh @@ -15,7 +15,9 @@ grep -Fxq "CI sentinel" "$target/.local/ci-sentinel" full_target="$(mktemp -d)" global_root="$(mktemp -d)" linked_target="$(mktemp -d)" -trap 'rm -rf "$target" "$full_target" "$global_root" "$linked_target"' EXIT +vault_target="$(mktemp -d)" +vault_root="$(mktemp -d)" +trap 'rm -rf "$target" "$full_target" "$global_root" "$linked_target" "$vault_target" "$vault_root"' EXIT bash "$repo_root/scripts/install.sh" --target "$full_target" --repository "$repository" --ref "$ref" --profile full --mode fail --skip-root-agents test -f "$full_target/.agents/skills/repository-learning/SKILL.md" @@ -46,4 +48,69 @@ test "$global_version" = "$linked_version" grep -Fxq "scope: global" "$global_root/learning-flow/.install-scope" grep -Fxq "scope: linked" "$linked_target/learning-flow/.install-scope" -echo "Installer smoke test passed for minimal, full, global, and linked scopes." +# LearningVault is a storage adapter for linked scope, not another scope. +# Registration must preserve the repository paths, use local Git excludes, and +# remain safe to repeat through a normal linked update. +git -C "$vault_target" init -q +bash "$repo_root/scripts/install.sh" \ + --target "$vault_target" \ + --scope linked \ + --repository "$repository" \ + --ref "$ref" \ + --mode fail \ + --skip-root-agents \ + --vault-register \ + --vault-path "$vault_root" +test -L "$vault_target/.local" +test -L "$vault_target/learning-flow" +test -L "$vault_target/agentic-flow" +test -f "$vault_root/AGENTS.md" +test -f "$vault_root/README.md" +test -f "$vault_root/scripts/register-vault.sh" +test ! -e "$vault_target/.gitignore" +test -z "$(git -C "$vault_root" remote)" +exclude_path="$(git -C "$vault_target" rev-parse --path-format=absolute --git-path info/exclude)" +grep -Fxq "/.local/" "$exclude_path" +grep -Fxq "/learning-flow/" "$exclude_path" +grep -Fxq "/agentic-flow/" "$exclude_path" +! grep -Fxq "/AGENTS.md" "$exclude_path" + +vault_id="$(basename "$(find "$vault_root/repositories" -mindepth 1 -maxdepth 1 -type d | sed -n '1p')")" +relocated_vault="${vault_root}-relocated" +mv "$vault_root" "$relocated_vault" +vault_root="$relocated_vault" +"$vault_root/scripts/register-vault.sh" relink \ + --source "$vault_target" \ + --vault-path "$vault_root" \ + --repository-id "$vault_id" +test "$(readlink "$vault_target/learning-flow")" = "$vault_root/repositories/$vault_id/learning-flow" + +bash "$repo_root/scripts/install.sh" \ + --target "$vault_target" \ + --scope linked \ + --repository "$repository" \ + --ref "$ref" \ + --mode update \ + --skip-root-agents \ + --vault-register \ + --vault-path "$vault_root" +"$vault_root/scripts/register-vault.sh" status \ + --source "$vault_target" \ + --vault-path "$vault_root" >/dev/null +"$vault_root/scripts/register-vault.sh" unregister \ + --restore \ + --source "$vault_target" \ + --vault-path "$vault_root" +test ! -L "$vault_target/learning-flow" +test -f "$vault_target/learning-flow/MAP.md" +! grep -Fq "codebase-learning-flow-vault" "$exclude_path" +git -C "$vault_target" add -f learning-flow/MAP.md +if "$vault_root/scripts/register-vault.sh" register \ + --source "$vault_target" \ + --vault-path "$vault_root" >/dev/null 2>&1; then + echo "LearningVault unexpectedly registered a tracked state path." >&2 + exit 1 +fi +test ! -L "$vault_target/learning-flow" + +echo "Installer smoke test passed for minimal, full, global, linked, and LearningVault modes." diff --git a/scripts/ci-release-test.sh b/scripts/ci-release-test.sh index 2b1fdf5..3136743 100755 --- a/scripts/ci-release-test.sh +++ b/scripts/ci-release-test.sh @@ -31,6 +31,9 @@ PACKAGE_ROOT="$(find "$INSPECT_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1) [ -f "$PACKAGE_ROOT/adoption/ADOPT.md" ] || fail "Package is missing adoption/ADOPT.md" [ -f "$PACKAGE_ROOT/adoption/README.md" ] || fail "Package is missing adoption/README.md" [ -f "$PACKAGE_ROOT/VERSION" ] || fail "Package is missing a VERSION file" +[ -f "$PACKAGE_ROOT/sample/vault/AGENTS.md" ] || fail "Package is missing the LearningVault AGENTS.md" +[ -f "$PACKAGE_ROOT/scripts/register-vault.sh" ] || fail "Package is missing register-vault.sh" +[ -f "$PACKAGE_ROOT/scripts/register-vault.ps1" ] || fail "Package is missing register-vault.ps1" rm -rf "$INSPECT_DIR" echo "OK: adoption resources and VERSION present in package" @@ -81,7 +84,12 @@ if (cd "$WORK_ROOT" && sh "$INSTALL_SH" --package-file "$PACKAGE_PATH" --scope l fi echo "OK: linked scope refuses to run before a global installation exists" -run_install "global" "$WORK_ROOT/global" --scope global --profile full --extension regulatory +run_install "global" "$WORK_ROOT/global" \ + --scope global \ + --profile full \ + --extension regulatory \ + --vault-init \ + --vault-path "$WORK_ROOT/LearningVault" [ -f "$WORK_ROOT/global/agentic-flow/AGENTS.md" ] || fail "global install has no agentic-flow/AGENTS.md" [ -f "$WORK_ROOT/global/skills/repository-learning/SKILL.md" ] || fail "global install has no managed skills" [ -f "$WORK_ROOT/global/skills/regulatory-knowledge/SKILL.md" ] || fail "global install has no extension skill" @@ -90,6 +98,8 @@ run_install "global" "$WORK_ROOT/global" --scope global --profile full --extensi [ ! -e "$WORK_ROOT/global/.local" ] || fail "global install created a .local/ workspace" [ ! -e "$WORK_ROOT/global/.gitignore" ] || fail "global install wrote a .gitignore" [ ! -e "$WORK_ROOT/global/AGENTS.md" ] || fail "global install wrote a root AGENTS.md" +[ -f "$WORK_ROOT/LearningVault/AGENTS.md" ] || fail "vault initialization did not install root guidance" +[ -z "$(git -C "$WORK_ROOT/LearningVault" remote)" ] || fail "vault initialization configured a remote" echo "OK: global scope installs framework content only" run_install "linked" "$WORK_ROOT/linked" --scope linked --skip-root-agents @@ -109,4 +119,17 @@ linked_global_version="$(sed -n 's/^global-version:[[:space:]]*//p' "$WORK_ROOT/ [ "$global_version" = "$linked_global_version" ] || fail "linked repository recorded $linked_global_version against a global installation at $global_version" echo "OK: global and linked scope markers agree on the framework version" +mkdir -p "$WORK_ROOT/vault-linked" +git -C "$WORK_ROOT/vault-linked" init -q +run_install "vault-linked" "$WORK_ROOT/vault-linked" \ + --scope linked \ + --skip-root-agents \ + --vault-register \ + --vault-path "$WORK_ROOT/LearningVault" +[ -L "$WORK_ROOT/vault-linked/learning-flow" ] || fail "vault-linked learning-flow is not a symbolic link" +[ ! -e "$WORK_ROOT/vault-linked/.gitignore" ] || fail "vault-linked install modified shared .gitignore" +vault_exclude="$(git -C "$WORK_ROOT/vault-linked" rev-parse --path-format=absolute --git-path info/exclude)" +grep -Fxq "/agentic-flow/" "$vault_exclude" || fail "vault-linked install did not write local Git excludes" +echo "OK: packaged release initializes and registers LearningVault state" + echo "All packaged-release checks passed for $PACKAGE_PATH" diff --git a/scripts/ci-vault-test.ps1 b/scripts/ci-vault-test.ps1 new file mode 100644 index 0000000..38b9b83 --- /dev/null +++ b/scripts/ci-vault-test.ps1 @@ -0,0 +1,140 @@ +[CmdletBinding()] +param( + [string]$Repository = $(if ($env:GITHUB_REPOSITORY) { $env:GITHUB_REPOSITORY } else { "legrab/codebase-learning-flow" }), + [string]$Ref = $(if ($env:GITHUB_SHA) { $env:GITHUB_SHA } else { "main" }), + [string]$PackageFile = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$root = Join-Path ([System.IO.Path]::GetTempPath()) ("learning-vault-ci-" + [Guid]::NewGuid().ToString("N")) +$globalRoot = Join-Path $root "global" +$sourceRoot = Join-Path $root "source" +$vaultRoot = Join-Path $root "LearningVault" +$previousGlobalRoot = $env:CODEBASE_LEARNING_FLOW_HOME + +try { + New-Item -ItemType Directory -Force -Path $sourceRoot | Out-Null + & git -C $sourceRoot init --quiet + if ($LASTEXITCODE -ne 0) { throw "Failed to initialize source test repository." } + + $env:CODEBASE_LEARNING_FLOW_HOME = $globalRoot + & "$repoRoot/scripts/install.ps1" ` + -Scope Global ` + -Repository $Repository ` + -Ref $Ref ` + -PackageFile $PackageFile ` + -Profile Full ` + -Mode Fail ` + -VaultInit ` + -VaultPath $vaultRoot + + & "$repoRoot/scripts/install.ps1" ` + -TargetPath $sourceRoot ` + -Scope Linked ` + -Repository $Repository ` + -Ref $Ref ` + -PackageFile $PackageFile ` + -Mode Fail ` + -RootAgents Skip ` + -VaultRegister ` + -VaultPath $vaultRoot + + foreach ($name in @(".local", "learning-flow", "agentic-flow")) { + $item = Get-Item -LiteralPath (Join-Path $sourceRoot $name) -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) { + throw "$name is not a LearningVault directory junction." + } + } + if (Test-Path -LiteralPath (Join-Path $sourceRoot ".gitignore")) { + throw "Vault registration modified the source repository's shared .gitignore." + } + foreach ($path in @("AGENTS.md", "README.md", "scripts/register-vault.ps1", "scripts/register-vault.sh")) { + if (-not (Test-Path -LiteralPath (Join-Path $vaultRoot $path) -PathType Leaf)) { + throw "LearningVault seed is missing $path." + } + } + if (@(& git -C $vaultRoot remote).Count -ne 0) { + throw "LearningVault initialization created a remote." + } + + $excludePath = (& git -C $sourceRoot rev-parse --path-format=absolute --git-path info/exclude | Select-Object -First 1) + $exclude = [System.IO.File]::ReadAllText($excludePath) + foreach ($entry in @("/.local/", "/learning-flow/", "/agentic-flow/")) { + if (-not $exclude.Contains($entry)) { throw "Source Git exclude is missing $entry." } + } + if ($exclude.Contains("/AGENTS.md")) { throw "Source Git exclude must not hide AGENTS.md." } + + $repositoryId = (Get-ChildItem -LiteralPath (Join-Path $vaultRoot "repositories") -Directory | Select-Object -First 1).Name + $relocatedVault = "$vaultRoot-relocated" + Copy-Item -LiteralPath $vaultRoot -Destination $relocatedVault -Recurse -Force + Remove-Item -LiteralPath $vaultRoot -Recurse -Force + $vaultRoot = $relocatedVault + & "$vaultRoot/scripts/register-vault.ps1" ` + relink ` + -RepositoryId $repositoryId ` + -SourcePath $sourceRoot ` + -VaultPath $vaultRoot + $learningTarget = @((Get-Item -LiteralPath (Join-Path $sourceRoot "learning-flow") -Force).Target) | Select-Object -First 1 + if (-not ([System.IO.Path]::GetFullPath($learningTarget).StartsWith($vaultRoot, [System.StringComparison]::OrdinalIgnoreCase))) { + throw "Relink did not update the junction after vault relocation." + } + + & "$repoRoot/scripts/install.ps1" ` + -TargetPath $sourceRoot ` + -Scope Linked ` + -Repository $Repository ` + -Ref $Ref ` + -PackageFile $PackageFile ` + -Mode Update ` + -RootAgents Skip ` + -VaultRegister ` + -VaultPath $vaultRoot + + & "$vaultRoot/scripts/register-vault.ps1" ` + unregister ` + -Restore ` + -SourcePath $sourceRoot ` + -VaultPath $vaultRoot + + if (-not (Test-Path -LiteralPath (Join-Path $sourceRoot "learning-flow/MAP.md") -PathType Leaf)) { + throw "Unregister did not restore repository learning state." + } + if ((Get-Item -LiteralPath (Join-Path $sourceRoot "learning-flow") -Force).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + throw "Unregister left the restored learning-flow as a junction." + } + if ([System.IO.File]::ReadAllText($excludePath).Contains("codebase-learning-flow-vault")) { + throw "Unregister did not remove its managed Git exclude block." + } + + & git -C $sourceRoot add -f learning-flow/MAP.md + if ($LASTEXITCODE -ne 0) { throw "Failed to stage the tracked-path refusal fixture." } + $refusedTrackedPath = $false + try { + & "$vaultRoot/scripts/register-vault.ps1" ` + register ` + -SourcePath $sourceRoot ` + -VaultPath $vaultRoot + } + catch { + $refusedTrackedPath = $_.Exception.Message -like "Refusing to vault tracked paths*" + } + if (-not $refusedTrackedPath) { + throw "LearningVault did not refuse a tracked repository-state path." + } + + Write-Host "PowerShell LearningVault lifecycle test passed." +} +finally { + if ($null -eq $previousGlobalRoot) { + Remove-Item Env:CODEBASE_LEARNING_FLOW_HOME -ErrorAction SilentlyContinue + } + else { + $env:CODEBASE_LEARNING_FLOW_HOME = $previousGlobalRoot + } + if (Test-Path -LiteralPath $root) { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } +} From 94b0c90543dcde9a4f5360edab4cb8c90de0f6de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Gell=C3=A9r?= Date: Wed, 26 Aug 2026 11:02:47 +0200 Subject: [PATCH 4/7] docs: define LearningVault ownership and operation Document the storage boundary, lifecycle commands, migration constraints, privacy model, and rationale for linked repository state. Co-authored-by: Cursor --- CHANGELOG.md | 27 ++++++++ MANIFEST.txt | 14 ++--- README.md | 36 +++++++++++ docs/ARCHITECTURE.md | 18 +++++- docs/DESIGN_NOTES.md | 52 ++++++++++++++++ .../.agents/skills/agentic-workflow/SKILL.md | 2 +- sample/common/agentic-flow/LOCAL.md | 2 +- scripts/README.md | 61 +++++++++++++++++++ 8 files changed, 200 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de72f35..f4b88ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### Added + +- Optional LearningVault initialization through `--vault-init` / + `-VaultInit`, with configurable `--vault-path` / `-VaultPath` and + `CODEBASE_LEARNING_VAULT`. +- Opt-in linked-repository registration through `--vault-register` / + `-VaultRegister`. Repository state is moved under + `LearningVault/repositories//` and exposed at its original + paths by POSIX symbolic links or Windows directory junctions. +- Cross-platform `register-vault` commands for idempotent registration, + status, relocation/relinking, and explicit restoration. +- A compact vault root `AGENTS.md`, README, and `.gitignore` for safe + cross-repository use without crawling every registered repository. +- Linux and Windows lifecycle coverage for packaged seeding, local Git + excludes, relocation, update, restore, tracked-path refusal, and the + no-remote boundary. + +### Changed + +- Combined linked installation and vault registration writes private harness + exclusions to `.git/info/exclude` instead of changing shared `.gitignore`. +- Conversion from a vault-linked installation to repository scope now requires + `unregister --restore` first, preventing installer replacement logic from + operating on a link node. + ## 1.4.0 Install scopes: the framework can now be installed once into `%USERPROFILE%\.agents\` (`~/.agents/`) and shared by every repository, while each repository keeps its own learning state locally. Repository-scoped installation is unchanged and remains the default. diff --git a/MANIFEST.txt b/MANIFEST.txt index f0d5c15..3de978b 100644 --- a/MANIFEST.txt +++ b/MANIFEST.txt @@ -1,14 +1,14 @@ .gitattributes 40 .gitignore 324 AGENTS.md 2995 -CHANGELOG.md 31134 +CHANGELOG.md 32368 LICENSE 2213 -README.md 12031 +README.md 13579 adoption/ADOPT.md 4177 adoption/README.md 2445 docs/AGENTIC_WORKFLOW_SANITY.md 10021 -docs/ARCHITECTURE.md 8577 -docs/DESIGN_NOTES.md 45480 +docs/ARCHITECTURE.md 9277 +docs/DESIGN_NOTES.md 48330 docs/EXAMPLE_WALKTHROUGH.md 3614 docs/EDUCATION_MODEL.md 5452 docs/INITIALIZE_LEARNING_FLOW.md 12199 @@ -21,7 +21,7 @@ docs/references/REFERENCE_REVIEW_LEARNING_FLOW_ADJUSTMENT.md 9505 docs/references/REFERENCE_REVIEW_LITT.md 2069 docs/references/REFERENCE_REVIEW_POCOK.md 2155 sample/README.md 3382 -sample/common/.agents/skills/agentic-workflow/SKILL.md 2828 +sample/common/.agents/skills/agentic-workflow/SKILL.md 2986 sample/common/.agents/skills/learn-anything/SKILL.md 1793 sample/common/.agents/skills/learn-anything/agents/openai.yaml 246 sample/common/.agents/skills/learning-closure/SKILL.md 3709 @@ -46,7 +46,7 @@ sample/common/agentic-flow/CONFIGURE.md 4469 sample/common/agentic-flow/DECISIONS.md 963 sample/common/agentic-flow/EDUCATION.md 5232 sample/common/agentic-flow/LEARN.md 1910 -sample/common/agentic-flow/LOCAL.md 4366 +sample/common/agentic-flow/LOCAL.md 4564 sample/common/agentic-flow/README.md 2897 sample/common/agentic-flow/REFERENCE_INTEGRATION.md 2183 sample/common/agentic-flow/ROOT_INTEGRATION.md 3803 @@ -97,7 +97,7 @@ sample/root/AGENTS.pointer.md 712 sample/vault/.gitignore 32 sample/vault/AGENTS.md 1580 sample/vault/README.md 1369 -scripts/README.md 10227 +scripts/README.md 12921 scripts/install.bat 1477 scripts/install.ps1 60821 scripts/install.sh 51063 diff --git a/README.md b/README.md index 20d443c..f1dbfc2 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,42 @@ The repository then holds only what it authors; the instructions and skills stay An existing installation can move between scopes: `--scope linked --mode update` strips the framework copies out of a repository and leaves its authored state behind, and `--scope repository --mode update` puts them back. +### Optional LearningVault + +Linked repositories normally keep their authored state in place. LearningVault +is an opt-in storage adapter that instead collects that state in one local-only +Git repository while preserving the source paths through Windows directory +junctions or POSIX symbolic links. + +```powershell +# Seed the vault while installing the global framework. +.\scripts\install.ps1 -Scope Global -Profile Full -VaultInit + +# In a source Git repository, install linked state and register it. +.\scripts\install.ps1 -Scope Linked -VaultRegister +``` + +```sh +sh scripts/install.sh --scope global --profile full --vault-init +sh scripts/install.sh --scope linked --vault-register +``` + +The default vault is `%USERPROFILE%\LearningVault` on Windows and +`$HOME/LearningVault` elsewhere. Override it with `-VaultPath` / +`--vault-path` or `CODEBASE_LEARNING_VAULT`. + +The vault stores each repository under `repositories//` and +ships its own compact `AGENTS.md`, README, registration scripts, and +`.gitignore`. The source repository keeps its physical root `AGENTS.md`; +`.local/`, `learning-flow/`, and `agentic-flow/` become links. Their exclusions +are written to `.git/info/exclude`, not shared `.gitignore`. + +Registration never creates a remote, stages files, or commits. Use +`register-vault status`, `relink`, and `unregister --restore` (PowerShell: +`-Restore`) for the rest of the lifecycle. A vault can contain private +continuity and Git history retains deleted content, so review it before +committing or adding any remote manually. +
Profiles, extensions, and update modes diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 193927a..c533d47 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -123,22 +123,34 @@ The three layers are content boundaries. Cutting across them is a second, indepe | Kind | Example | Root | |---|---|---| | Framework-owned | `agentic-flow/AGENTS.md`, `learning-flow/AGENTS.md`, every managed skill | repository or `~/.agents/` | -| Repository-authored | `MAP.md`, `TAKEAWAYS.md`, `REPOSITORIES.md`, `SETTINGS.md`, `DECISIONS.md`, `.local/` | always the repository | +| Repository-authored | `MAP.md`, `TAKEAWAYS.md`, `REPOSITORIES.md`, `SETTINGS.md`, `DECISIONS.md`, `.local/` | logically the repository; physically in place by default or under optional LearningVault | Framework-owned content is identical in every repository, so it can be installed once globally and shared. Repository-authored content describes one system and cannot be. This is the same line the installer's `.managed-files` manifests already drew to decide what `update` may overwrite; `.repository-files` names the other side of it explicitly so both can be installed independently. Instructions resolve at the repository root first and fall back to the global root, so a self-contained repository never consults the global installation. There is no merging between roots: whichever answers first is the one that applies. +LearningVault is an optional physical-storage adapter for the repository side +of this boundary. It is not another framework root or install scope. A linked +repository may expose `.local/`, `learning-flow/`, and `agentic-flow/` through +directory links into `~/LearningVault/repositories//`. Root +`AGENTS.md` remains a real source-repository file, and managed instructions and +skills remain under `~/.agents`. + ```mermaid flowchart LR T[Task in a repository] --> R{Repository has agentic-flow/?} R -->|yes| L[Read repository copy] R -->|no| G[Read ~/.agents copy] - L --> S[Repository state: MAP, TAKEAWAYS, SETTINGS, .local] + L --> S[Repository paths: MAP, TAKEAWAYS, SETTINGS, .local] G --> S + S --> P{Vault registered?} + P -->|no| D[Physical state in source repository] + P -->|yes| V[Physical state in LearningVault] ``` -Repository state sits below the fork because it is read from the repository either way. +Repository state sits below the fork because it is read through repository +paths either way. Vault storage does not transfer ownership to a global +knowledge base. ## Runtime instruction flow diff --git a/docs/DESIGN_NOTES.md b/docs/DESIGN_NOTES.md index cdff9bf..d3fb8f3 100644 --- a/docs/DESIGN_NOTES.md +++ b/docs/DESIGN_NOTES.md @@ -4,6 +4,58 @@ The harness should keep a developer able to reason about a repository while collaborating with an agent, and let any learner use the same lightweight methods for a general subject. It should improve delivery, code and architecture understanding, domain reasoning, debugging, ownership growth, and conversational learning without making workflow administration or learning administration the primary activity. +## Unreleased: optional LearningVault storage + +The global install introduced in 1.4 deliberately left repository state in +each repository. That remains the default and the ownership model. The missing +use case was physical aggregation: one developer may want maps, takeaways, +settings, and private continuity from several dependent repositories visible +in one local Git client without moving reusable framework files out of +`~/.agents`. + +LearningVault addresses only that storage concern. It is not a fourth install +scope. A vault registration requires `linked` scope, moves `.local/`, +`learning-flow/`, and `agentic-flow/` under +`~/LearningVault/repositories//`, and preserves their source +paths through Windows directory junctions or POSIX symbolic links. Root +`AGENTS.md` remains a physical source-repository file because Git operations +can replace tracked files and silently sever hard links. + +This is intentionally narrower than the symlink design rejected in 1.4. That +decision concerned shared framework files and ambiguous `update` ownership. +LearningVault links only repository-authored state after the framework/state +boundary has already been established by `linked` scope. Managed framework +updates continue under `~/.agents`; repository seeds remain copy-if-missing +through their source paths. + +### Alternatives rejected + +- Copy/synchronization would create two writable copies and require a new + conflict protocol. +- A `vault` install scope would mix framework placement with repository-state + storage and duplicate the existing linked workflow. +- Vaulting only `.local/` would not provide the cross-repository map and + settings workflow that motivated the feature. +- Automatically untracking repository files would turn a local storage choice + into an unreviewed team-visible migration. + +### Lifecycle and safety boundaries + +- The installer seeds the vault and may invoke registration, while standalone + registration scripts own register, status, relink, and restore. This keeps + filesystem migration out of ordinary install/update paths. +- Link support is probed before migration. Source/vault conflicts and tracked + state are refused. Moved directories are rolled back when linking fails. +- Registration owns one marked `.git/info/exclude` block and never rewrites + unrelated entries or excludes root `AGENTS.md`. +- Repository IDs use repository name plus a hash of origin URL when available, + otherwise source path; an explicit ID repairs origin-less relocations. +- The vault initializes a local Git repository but never creates a remote, + stages files, or commits. Users must treat its history as private because + deleted sensitive material remains in prior commits. +- Empty registrations remain visible through `VAULT.md`; this also records the + source path, origin, and link kind needed for recovery. + ## v1.4 install scopes: one framework, many repositories Until 1.4 the framework had exactly one install root. A developer who wanted this behavior in fifteen repositories installed and updated fifteen byte-identical copies of `agentic-flow/`, `learning-flow/`, and every managed skill, and had no way at all to get the behavior in a repository they could not or should not modify. The layer architecture was already right; the *deployment* model assumed the repository was the only place content could live. diff --git a/sample/common/.agents/skills/agentic-workflow/SKILL.md b/sample/common/.agents/skills/agentic-workflow/SKILL.md index 461b6ab..79e0a0a 100644 --- a/sample/common/.agents/skills/agentic-workflow/SKILL.md +++ b/sample/common/.agents/skills/agentic-workflow/SKILL.md @@ -11,7 +11,7 @@ Read `agentic-flow/README.md` and `AGENTS.md` (repository root, else `~/.agents/ 1. Inspect root and nested instructions plus tool-specific files. 2. Find skills, prompts, plans, sessions, records, and refresh rules. -3. Detect managed template markers, including `learning-flow/.install-scope`, and note which root the framework files actually resolve from. +3. Detect managed template markers, including `learning-flow/.install-scope`, and note which root the framework files actually resolve from. When repository state is linked into LearningVault, use that repository's `VAULT.md` as the storage index without treating the vault as the repository owner. 4. Inspect custom additions, overrides, conflicts, and precedence. 5. Keep stable policy, task procedures, shared learning, and private `.local/` state distinct. 6. Keep context narrow. diff --git a/sample/common/agentic-flow/LOCAL.md b/sample/common/agentic-flow/LOCAL.md index 8eedc89..a658687 100644 --- a/sample/common/agentic-flow/LOCAL.md +++ b/sample/common/agentic-flow/LOCAL.md @@ -4,7 +4,7 @@ Use one rule: > Learn locally first. Promote only reusable knowledge deliberately. -The repository-root `.local/` directory owns private learning continuity. The installer creates it, adds `/.local/` to the root `.gitignore`, and never overwrites existing local files. This stays true for a global installation: instructions and skills may live in `~/.agents/`, but `.local/` always belongs to the repository being worked on. There is no global `.local/`. +The repository-root `.local/` directory owns private learning continuity. The installer creates it, adds `/.local/` to the root `.gitignore`, and never overwrites existing local files. In optional LearningVault mode, the path is instead a junction or symbolic link to that repository's vault directory and is hidden through local `.git/info/exclude`; logical ownership is unchanged. This stays true for a global installation: instructions and skills may live in `~/.agents/`, but `.local/` always belongs to the repository being worked on. There is no global `.local/`. This framework's own source checkout follows the same model lazily: when `.local/` is missing, create the two directories below and copy `sample/common/local/learning-history.md` only if the local history file does not exist. diff --git a/scripts/README.md b/scripts/README.md index a409569..45cf354 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -122,6 +122,67 @@ Under `global`, managed skills install to `/skills/` rather than `/.
+## Optional LearningVault storage + +LearningVault does not add another install scope. It changes only the physical +storage of repository-authored state and therefore requires `linked` scope. +The global installation remains under `~/.agents`; the vault defaults to +`$HOME/LearningVault` (`%USERPROFILE%\LearningVault` on Windows). + +```text +--vault-init +--vault-register +--vault-path PATH + +-VaultInit +-VaultRegister +-VaultPath PATH +``` + +- `vault-init` initializes the vault as a local Git repository, copies its + README, root `AGENTS.md`, and `.gitignore` only when missing, and refreshes + its installer-owned registration scripts; +- `vault-register` implies initialization and, after a successful linked + install, moves `.local/`, `learning-flow/`, and `agentic-flow/` into the + vault and links them back; +- `CODEBASE_LEARNING_VAULT` overrides the default root when no path option is + supplied. + +The combined registration path does not add `/.local/` to shared `.gitignore`. +Instead, registration owns one marked block in the source Git repository's +`.git/info/exclude` for the three linked directories. Existing unrelated +exclude entries are preserved, and root `AGENTS.md` is not excluded. + +```powershell +& "$HOME\LearningVault\scripts\register-vault.ps1" status +& "$HOME\LearningVault\scripts\register-vault.ps1" relink -RepositoryId +& "$HOME\LearningVault\scripts\register-vault.ps1" unregister -Restore +``` + +```sh +"$HOME/LearningVault/scripts/register-vault.sh" status +"$HOME/LearningVault/scripts/register-vault.sh" relink --repository-id +"$HOME/LearningVault/scripts/register-vault.sh" unregister --restore +``` + +Registration is transactional across the three state directories: it +preflights link support, refuses source/vault conflicts, and restores moved +directories when link creation fails. Rerunning against the same targets is +idempotent. `relink` repairs absolute junction/symlink targets after the vault +or source is moved. `unregister` requires explicit restoration so it cannot +silently leave a repository without its state. + +Tracked `agentic-flow`, `learning-flow`, or `.local` content is refused rather +than automatically removed from the source repository's index. Resolve that +team-visible migration deliberately first. Each Git worktree is a separate +registration because links live in the worktree filesystem; nested invocations +must target the repository top level. + +The vault never creates/configures a remote, stages files, or commits. +Repository IDs combine a sanitized repository name with a stable hash of the +origin URL when one exists, otherwise the absolute source path. Use an explicit +ID when relinking a relocated repository that has no origin. + ## Version and scope marker Each root records `learning-flow/.install-scope`: From 1559c2bb84763e10b84cf74f6f13bd75a4b20e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Gell=C3=A9r?= Date: Wed, 26 Aug 2026 11:04:52 +0200 Subject: [PATCH 5/7] fix: harden LearningVault initialization rollback Ensure nested paths become independent vault repositories and restore prior moves when POSIX migration setup fails. Co-authored-by: Cursor --- MANIFEST.txt | 8 ++++---- scripts/install.ps1 | 10 ++++++++-- scripts/install.sh | 4 +++- scripts/register-vault.ps1 | 4 ++-- scripts/register-vault.sh | 18 +++++++++++++++--- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/MANIFEST.txt b/MANIFEST.txt index 3de978b..3521858 100644 --- a/MANIFEST.txt +++ b/MANIFEST.txt @@ -99,10 +99,10 @@ sample/vault/AGENTS.md 1580 sample/vault/README.md 1369 scripts/README.md 12921 scripts/install.bat 1477 -scripts/install.ps1 60821 -scripts/install.sh 51063 -scripts/register-vault.ps1 18800 -scripts/register-vault.sh 13633 +scripts/install.ps1 61089 +scripts/install.sh 51169 +scripts/register-vault.ps1 18841 +scripts/register-vault.sh 14133 skill-evals/README.md 1689 skill-evals/adoption-cases.yaml 1337 skill-evals/agentic-cases.yaml 9830 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d1affbf..83a5fb3 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -209,8 +209,14 @@ function Initialize-LearningVault( throw "Git is required to initialize LearningVault." } New-Item -ItemType Directory -Path $Root -Force | Out-Null - $inside = @(& git -C $Root rev-parse --is-inside-work-tree 2>$null) - if ($LASTEXITCODE -ne 0 -or ($inside | Select-Object -First 1) -ne "true") { + $top = @(& git -C $Root rev-parse --show-toplevel 2>$null) | Select-Object -First 1 + $isVaultRoot = $LASTEXITCODE -eq 0 -and + -not [string]::IsNullOrWhiteSpace($top) -and + ([System.IO.Path]::GetFullPath($top).TrimEnd('\', '/')).Equals( + [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/'), + [System.StringComparison]::OrdinalIgnoreCase + ) + if (-not $isVaultRoot) { & git -C $Root init | Out-Null if ($LASTEXITCODE -ne 0) { throw "Failed to initialize LearningVault at $Root." } Write-Step "Initialized local LearningVault Git repository at $Root" diff --git a/scripts/install.sh b/scripts/install.sh index b8e1c14..3b63a0a 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -152,7 +152,9 @@ initialize_learning_vault() { command -v git >/dev/null 2>&1 || { echo "Git is required to initialize LearningVault." >&2; exit 1; } mkdir -p "$vault_root" - if [ "$(git -C "$vault_root" rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]; then + vault_top="$(git -C "$vault_root" rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$vault_top" ]; then vault_top="$(cd "$vault_top" && pwd -P)"; fi + if [ "$vault_top" != "$vault_root" ]; then git -C "$vault_root" init >/dev/null log "Initialized local LearningVault Git repository at $vault_root" fi diff --git a/scripts/register-vault.ps1 b/scripts/register-vault.ps1 index 9aa0798..bd12539 100644 --- a/scripts/register-vault.ps1 +++ b/scripts/register-vault.ps1 @@ -74,8 +74,8 @@ function Initialize-VaultRepository([string]$Root) { if (-not (Test-Path -LiteralPath $Root -PathType Container)) { New-Item -ItemType Directory -Path $Root -Force | Out-Null } - $inside = Invoke-Git -WorkingDirectory $Root -Arguments @("rev-parse", "--is-inside-work-tree") -AllowFailure - if (($inside | Select-Object -First 1) -ne "true") { + $top = Invoke-Git -WorkingDirectory $Root -Arguments @("rev-parse", "--show-toplevel") -AllowFailure | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($top) -or -not (Test-SamePath $top $Root)) { & git -C $Root init | Out-Null if ($LASTEXITCODE -ne 0) { throw "Failed to initialize the LearningVault Git repository at $Root." } Write-Step "Initialized local Git repository at $Root" diff --git a/scripts/register-vault.sh b/scripts/register-vault.sh index 09cbc0f..c6c423c 100644 --- a/scripts/register-vault.sh +++ b/scripts/register-vault.sh @@ -64,7 +64,9 @@ fi mkdir -p "$VAULT_PATH" VAULT_ROOT="$(cd "$VAULT_PATH" && pwd -P)" -if [ "$(git -C "$VAULT_ROOT" rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]; then +vault_top="$(git -C "$VAULT_ROOT" rev-parse --show-toplevel 2>/dev/null || true)" +if [ -n "$vault_top" ]; then vault_top="$(cd "$vault_top" && pwd -P)"; fi +if [ "$vault_top" != "$VAULT_ROOT" ]; then git -C "$VAULT_ROOT" init >/dev/null log "Initialized local Git repository at $VAULT_ROOT" fi @@ -302,10 +304,20 @@ register_repository() { destination="$registration/$name" if [ -L "$source" ]; then continue; fi if [ -e "$source" ]; then - mv "$source" "$destination" + if ! mv "$source" "$destination"; then + rollback + trap - HUP INT TERM + echo "Failed to move $source into LearningVault; prior moves were restored." >&2 + exit 1 + fi moved="$name $moved" elif [ ! -e "$destination" ]; then - mkdir -p "$destination" + if ! mkdir -p "$destination"; then + rollback + trap - HUP INT TERM + echo "Failed to create $destination; prior moves were restored." >&2 + exit 1 + fi fi if ! ln -s "$destination" "$source"; then rollback From 9c0d738f3beb911ee5ce9401712e642001bc616f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Gell=C3=A9r?= Date: Wed, 26 Aug 2026 11:18:50 +0200 Subject: [PATCH 6/7] fix: protect LearningVault edge-case migrations Separate worktree identities, preserve malformed excludes, restore failed unregisters, and keep private state ignored when registration fails. Co-authored-by: Cursor --- MANIFEST.txt | 12 +++---- docs/DESIGN_NOTES.md | 5 +-- scripts/README.md | 9 ++--- scripts/ci-install-test.sh | 10 ++++++ scripts/ci-vault-test.ps1 | 22 ++++++++++++ scripts/install.ps1 | 14 +++++--- scripts/install.sh | 17 ++++++++-- scripts/register-vault.ps1 | 68 ++++++++++++++++++++++++++++++++------ scripts/register-vault.sh | 59 +++++++++++++++++++++++++++++---- 9 files changed, 182 insertions(+), 34 deletions(-) diff --git a/MANIFEST.txt b/MANIFEST.txt index 3521858..2bfa2d0 100644 --- a/MANIFEST.txt +++ b/MANIFEST.txt @@ -8,7 +8,7 @@ adoption/ADOPT.md 4177 adoption/README.md 2445 docs/AGENTIC_WORKFLOW_SANITY.md 10021 docs/ARCHITECTURE.md 9277 -docs/DESIGN_NOTES.md 48330 +docs/DESIGN_NOTES.md 48403 docs/EXAMPLE_WALKTHROUGH.md 3614 docs/EDUCATION_MODEL.md 5452 docs/INITIALIZE_LEARNING_FLOW.md 12199 @@ -97,12 +97,12 @@ sample/root/AGENTS.pointer.md 712 sample/vault/.gitignore 32 sample/vault/AGENTS.md 1580 sample/vault/README.md 1369 -scripts/README.md 12921 +scripts/README.md 13020 scripts/install.bat 1477 -scripts/install.ps1 61089 -scripts/install.sh 51169 -scripts/register-vault.ps1 18841 -scripts/register-vault.sh 14133 +scripts/install.ps1 61308 +scripts/install.sh 51826 +scripts/register-vault.ps1 21228 +scripts/register-vault.sh 16299 skill-evals/README.md 1689 skill-evals/adoption-cases.yaml 1337 skill-evals/agentic-cases.yaml 9830 diff --git a/docs/DESIGN_NOTES.md b/docs/DESIGN_NOTES.md index d3fb8f3..5b823dd 100644 --- a/docs/DESIGN_NOTES.md +++ b/docs/DESIGN_NOTES.md @@ -48,8 +48,9 @@ through their source paths. state are refused. Moved directories are rolled back when linking fails. - Registration owns one marked `.git/info/exclude` block and never rewrites unrelated entries or excludes root `AGENTS.md`. -- Repository IDs use repository name plus a hash of origin URL when available, - otherwise source path; an explicit ID repairs origin-less relocations. +- Repository IDs use repository name plus a hash of origin URL (when + available) and absolute worktree path. This prevents clones or worktrees of + one remote from sharing state; an explicit recorded ID repairs relocations. - The vault initializes a local Git repository but never creates a remote, stages files, or commits. Users must treat its history as private because deleted sensitive material remains in prior commits. diff --git a/scripts/README.md b/scripts/README.md index 45cf354..aa12e93 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -117,7 +117,7 @@ Under `global`, managed skills install to `/skills/` rather than `/. - `linked` inherits the global installation's profile and extension. Passing a conflicting `--profile` or `--extension` is an error: the repository would be seeded for a routing contract it does not read. - Repository-authored seeds are copied only when missing, in every mode. There is no framework content in a linked repository for `update` or `replace` to refresh, so those modes cannot destroy authored learning state. - `repository` → `linked` requires `update` or `replace`. It removes the repository's managed files and managed skills through their own manifests and leaves authored files in place. -- `linked` → `repository` requires `merge`, `update`, or `replace`, and inherits the profile and extension the global installation was providing. +- `linked` → `repository` requires `merge`, `update`, or `replace`, and inherits the profile and extension the global installation was providing. A vault-linked repository must run `unregister --restore` first. - Installing `--scope repository` while a global installation exists is allowed but warned about: the host agent would discover every managed skill twice. @@ -179,9 +179,10 @@ registration because links live in the worktree filesystem; nested invocations must target the repository top level. The vault never creates/configures a remote, stages files, or commits. -Repository IDs combine a sanitized repository name with a stable hash of the -origin URL when one exists, otherwise the absolute source path. Use an explicit -ID when relinking a relocated repository that has no origin. +Repository IDs combine a sanitized repository name with a hash of the origin +URL (when one exists) and absolute worktree path. This keeps clones and +worktrees separate. Use the recorded or explicit ID when relinking after a +source or vault relocation. ## Version and scope marker diff --git a/scripts/ci-install-test.sh b/scripts/ci-install-test.sh index f61b1a0..9a23971 100755 --- a/scripts/ci-install-test.sh +++ b/scripts/ci-install-test.sh @@ -112,5 +112,15 @@ if "$vault_root/scripts/register-vault.sh" register \ exit 1 fi test ! -L "$vault_target/learning-flow" +git -C "$vault_target" rm --cached --force --quiet learning-flow/MAP.md +printf '%s\n' '# codebase-learning-flow-vault:start' 'unrelated-entry' > "$exclude_path" +if "$vault_root/scripts/register-vault.sh" register \ + --source "$vault_target" \ + --vault-path "$vault_root" >/dev/null 2>&1; then + echo "LearningVault unexpectedly rewrote malformed local exclude markers." >&2 + exit 1 +fi +test ! -L "$vault_target/learning-flow" +grep -Fxq "unrelated-entry" "$exclude_path" echo "Installer smoke test passed for minimal, full, global, linked, and LearningVault modes." diff --git a/scripts/ci-vault-test.ps1 b/scripts/ci-vault-test.ps1 index 38b9b83..73d59c7 100644 --- a/scripts/ci-vault-test.ps1 +++ b/scripts/ci-vault-test.ps1 @@ -125,6 +125,28 @@ try { throw "LearningVault did not refuse a tracked repository-state path." } + & git -C $sourceRoot rm --cached --force --quiet learning-flow/MAP.md + if ($LASTEXITCODE -ne 0) { throw "Failed to clear the tracked-path refusal fixture." } + [System.IO.File]::WriteAllText( + $excludePath, + "# codebase-learning-flow-vault:start`nunrelated-entry`n", + [System.Text.UTF8Encoding]::new($false) + ) + $refusedMalformedExclude = $false + try { + & "$vaultRoot/scripts/register-vault.ps1" ` + register ` + -SourcePath $sourceRoot ` + -VaultPath $vaultRoot + } + catch { + $refusedMalformedExclude = $_.Exception.Message -like "Refusing to rewrite malformed LearningVault markers*" + } + if (-not $refusedMalformedExclude -or + ((Get-Item -LiteralPath (Join-Path $sourceRoot "learning-flow") -Force).Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "Malformed local exclude markers were not refused before migration." + } + Write-Host "PowerShell LearningVault lifecycle test passed." } finally { diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 83a5fb3..8242c44 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1199,10 +1199,16 @@ try { -PowerShellRegistrationScript $sourceVaultPowerShell ` -ShellRegistrationScript $sourceVaultShell if ($VaultRegister) { - & (Join-Path $resolvedVault "scripts/register-vault.ps1") ` - register ` - -SourcePath $resolvedTarget ` - -VaultPath $resolvedVault + try { + & (Join-Path $resolvedVault "scripts/register-vault.ps1") ` + register ` + -SourcePath $resolvedTarget ` + -VaultPath $resolvedVault + } + catch { + Initialize-LocalLearningWorkspace -TargetRoot $resolvedTarget -HistoryTemplate $sourceLocalHistory + throw + } } } diff --git a/scripts/install.sh b/scripts/install.sh index 3b63a0a..6033909 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -171,6 +171,16 @@ initialize_learning_vault() { log "LearningVault ready at $vault_root" } +has_learning_vault_registration() { + repository_root="$1" + command -v git >/dev/null 2>&1 || return 1 + git_directory="$(git -C "$repository_root" rev-parse --git-dir 2>/dev/null || true)" + [ -n "$git_directory" ] || return 1 + case "$git_directory" in /*) ;; *) git_directory="$repository_root/$git_directory" ;; esac + [ -f "$git_directory/info/exclude" ] || return 1 + grep -Fqx '# codebase-learning-flow-vault:start' "$git_directory/info/exclude" +} + require_value() { option="$1" remaining="$2" @@ -913,7 +923,7 @@ if [ -n "$INSTALLED_SCOPE" ] && [ "$INSTALLED_SCOPE" != "$SCOPE" ]; then esac ;; linked/repository) - if [ -L "$TARGET_AGENTIC" ] || [ -L "$TARGET_LEARNING" ] || [ -L "$TARGET_PATH/.local" ]; then + if [ -L "$TARGET_AGENTIC" ] || [ -L "$TARGET_LEARNING" ] || [ -L "$TARGET_PATH/.local" ] || has_learning_vault_registration "$TARGET_PATH"; then echo "This linked installation uses LearningVault directory links. Run register-vault.sh unregister --restore before converting it to repository scope." >&2 exit 1 fi @@ -1272,7 +1282,10 @@ if [ "$VAULT_INIT" = "true" ]; then RESOLVED_VAULT="$(cd "$RESOLVED_VAULT" && pwd)" initialize_learning_vault "$RESOLVED_VAULT" "$SOURCE_VAULT" "$SOURCE_VAULT_POWERSHELL" "$SOURCE_VAULT_SHELL" if [ "$VAULT_REGISTER" = "true" ]; then - sh "$RESOLVED_VAULT/scripts/register-vault.sh" register --source "$TARGET_PATH" --vault-path "$RESOLVED_VAULT" + if ! sh "$RESOLVED_VAULT/scripts/register-vault.sh" register --source "$TARGET_PATH" --vault-path "$RESOLVED_VAULT"; then + initialize_local_learning_workspace "$TARGET_PATH" "$SOURCE_LOCAL_HISTORY" "false" + exit 1 + fi fi fi diff --git a/scripts/register-vault.ps1 b/scripts/register-vault.ps1 index bd12539..5dfeb3b 100644 --- a/scripts/register-vault.ps1 +++ b/scripts/register-vault.ps1 @@ -86,6 +86,19 @@ function Initialize-VaultRepository([string]$Root) { New-Item -ItemType Directory -Path (Join-Path $Root "repositories") -Force | Out-Null } +function Assert-VaultRepository([string]$Root) { + if (-not (Test-Path -LiteralPath $Root -PathType Container)) { + throw "LearningVault does not exist: $Root" + } + $top = Invoke-Git -WorkingDirectory $Root -Arguments @("rev-parse", "--show-toplevel") -AllowFailure | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($top) -or -not (Test-SamePath $top $Root)) { + throw "Path is not a LearningVault Git repository root: $Root" + } + if (-not (Test-Path -LiteralPath (Join-Path $Root "repositories") -PathType Container)) { + throw "LearningVault is missing its repositories directory: $Root" + } +} + function Get-Sha256Prefix([string]$Value) { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) $sha = [System.Security.Cryptography.SHA256]::Create() @@ -105,10 +118,10 @@ function Get-RepositoryIdentity([string]$SourceRoot) { $origin = (Invoke-Git -WorkingDirectory $SourceRoot -Arguments @("config", "--get", "remote.origin.url") -AllowFailure | Select-Object -First 1) if (-not [string]::IsNullOrWhiteSpace($origin)) { $trimmed = $origin.Trim().TrimEnd('/').TrimEnd('\') - $name = [System.IO.Path]::GetFileNameWithoutExtension(($trimmed -replace "\\", "/")) + $name = (($trimmed -replace "\\", "/") -split "/")[-1] return [pscustomobject]@{ Origin = $trimmed - Identity = $trimmed.ToLowerInvariant() + Identity = "$($trimmed.ToLowerInvariant())`n$($SourceRoot.ToLowerInvariant())" Name = (ConvertTo-SafeId $name) } } @@ -202,14 +215,27 @@ function Test-LinkCapability([string]$VaultRoot) { function Get-ExcludePath([string]$SourceRoot) { $path = (Invoke-Git -WorkingDirectory $SourceRoot -Arguments @("rev-parse", "--path-format=absolute", "--git-path", "info/exclude") -AllowFailure | Select-Object -First 1) if ([string]::IsNullOrWhiteSpace($path)) { - $path = (Invoke-Git -WorkingDirectory $SourceRoot -Arguments @("rev-parse", "--git-path", "info/exclude") | Select-Object -First 1) - if (-not [System.IO.Path]::IsPathRooted($path)) { $path = Join-Path $SourceRoot $path } + $gitDirectory = (Invoke-Git -WorkingDirectory $SourceRoot -Arguments @("rev-parse", "--git-dir") | Select-Object -First 1) + if (-not [System.IO.Path]::IsPathRooted($gitDirectory)) { $gitDirectory = Join-Path $SourceRoot $gitDirectory } + $path = Join-Path $gitDirectory "info/exclude" } return [System.IO.Path]::GetFullPath($path) } +function Assert-ExcludeBlockWellFormed([string]$SourceRoot) { + $path = Get-ExcludePath $SourceRoot + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return } + $content = [System.IO.File]::ReadAllText($path) + $startCount = [regex]::Matches($content, "(?m)^$([regex]::Escape($ExcludeStart))\r?$").Count + $endCount = [regex]::Matches($content, "(?m)^$([regex]::Escape($ExcludeEnd))\r?$").Count + if ($startCount -ne $endCount -or $startCount -gt 1) { + throw "Refusing to rewrite malformed LearningVault markers in $path. Repair the marked block first." + } +} + function Set-ExcludeBlock([string]$SourceRoot, [bool]$Present) { $path = Get-ExcludePath $SourceRoot + Assert-ExcludeBlockWellFormed $SourceRoot $parent = Split-Path -Parent $path New-Item -ItemType Directory -Path $parent -Force | Out-Null $content = if (Test-Path -LiteralPath $path -PathType Leaf) { @@ -295,6 +321,7 @@ function Find-RegistrationId([string]$SourceRoot, [string]$VaultRoot, [string]$R function Register-Repository([string]$SourceRoot, [string]$VaultRoot, [string]$Id) { Assert-LinkedInstall $SourceRoot Assert-StateUntracked $SourceRoot + Assert-ExcludeBlockWellFormed $SourceRoot Test-LinkCapability $VaultRoot $registration = Join-Path (Join-Path $VaultRoot "repositories") $Id @@ -381,11 +408,31 @@ function Unregister-Repository([string]$SourceRoot, [string]$VaultRoot, [string] throw "Cannot restore because the vault copy is missing: $destination" } } - foreach ($name in $StateDirectories) { - $source = Join-Path $SourceRoot $name - $destination = Join-Path $registration $name - if (Test-ReparsePoint $source) { Remove-StateLink $source } - Move-Item -LiteralPath $destination -Destination $source + $restored = [System.Collections.Generic.List[object]]::new() + try { + foreach ($name in $StateDirectories) { + $source = Join-Path $SourceRoot $name + $destination = Join-Path $registration $name + if (Test-ReparsePoint $source) { Remove-StateLink $source } + Move-Item -LiteralPath $destination -Destination $source + $restored.Add([pscustomobject]@{ Source = $source; Destination = $destination }) + } + } + catch { + for ($index = $restored.Count - 1; $index -ge 0; $index--) { + $entry = $restored[$index] + if ((Test-Path -LiteralPath $entry.Source) -and -not (Test-Path -LiteralPath $entry.Destination)) { + Move-Item -LiteralPath $entry.Source -Destination $entry.Destination + } + } + foreach ($name in $StateDirectories) { + $source = Join-Path $SourceRoot $name + $destination = Join-Path $registration $name + if (-not (Test-ReparsePoint $source) -and -not (Test-Path -LiteralPath $source) -and (Test-Path -LiteralPath $destination)) { + New-StateLink -Path $source -Target $destination + } + } + throw } Set-ExcludeBlock -SourceRoot $SourceRoot -Present $false $metadata = Join-Path $registration "VAULT.md" @@ -416,13 +463,14 @@ function Show-Status([string]$SourceRoot, [string]$VaultRoot, [string]$Id) { $sourceRoot = Resolve-SourceRoot $SourcePath $vaultRoot = Resolve-VaultRoot $VaultPath -Initialize-VaultRepository $vaultRoot if ($Action -eq "register") { + Initialize-VaultRepository $vaultRoot $id = Get-RepositoryId -SourceRoot $sourceRoot -RequestedId $RepositoryId Register-Repository -SourceRoot $sourceRoot -VaultRoot $vaultRoot -Id $id } else { + Assert-VaultRepository $vaultRoot $id = Find-RegistrationId -SourceRoot $sourceRoot -VaultRoot $vaultRoot -RequestedId $RepositoryId switch ($Action) { "relink" { Relink-Repository -SourceRoot $sourceRoot -VaultRoot $vaultRoot -Id $id } diff --git a/scripts/register-vault.sh b/scripts/register-vault.sh index c6c423c..41caae9 100644 --- a/scripts/register-vault.sh +++ b/scripts/register-vault.sh @@ -61,19 +61,31 @@ if [ -z "$VAULT_PATH" ]; then [ -n "${HOME:-}" ] || { echo "Cannot resolve LearningVault: pass --vault-path or set CODEBASE_LEARNING_VAULT." >&2; exit 1; } VAULT_PATH="$HOME/LearningVault" fi +[ "$ACTION" = "register" ] || [ -d "$VAULT_PATH" ] || { + echo "LearningVault does not exist: $VAULT_PATH" >&2 + exit 1 +} mkdir -p "$VAULT_PATH" VAULT_ROOT="$(cd "$VAULT_PATH" && pwd -P)" vault_top="$(git -C "$VAULT_ROOT" rev-parse --show-toplevel 2>/dev/null || true)" if [ -n "$vault_top" ]; then vault_top="$(cd "$vault_top" && pwd -P)"; fi -if [ "$vault_top" != "$VAULT_ROOT" ]; then +if [ "$ACTION" = "register" ] && [ "$vault_top" != "$VAULT_ROOT" ]; then git -C "$VAULT_ROOT" init >/dev/null log "Initialized local Git repository at $VAULT_ROOT" +elif [ "$ACTION" != "register" ] && [ "$vault_top" != "$VAULT_ROOT" ]; then + echo "Path is not a LearningVault Git repository root: $VAULT_ROOT" >&2 + exit 1 fi if [ -n "$(git -C "$VAULT_ROOT" remote 2>/dev/null || true)" ]; then log "WARNING: this LearningVault has a Git remote. Registration will not modify it." fi -mkdir -p "$VAULT_ROOT/repositories" +if [ "$ACTION" = "register" ]; then + mkdir -p "$VAULT_ROOT/repositories" +elif [ ! -d "$VAULT_ROOT/repositories" ]; then + echo "LearningVault is missing its repositories directory: $VAULT_ROOT" >&2 + exit 1 +fi absolute_path() { path="$1" @@ -136,7 +148,7 @@ safe_id() { repository_identity() { origin="$(git -C "$SOURCE_ROOT" config --get remote.origin.url 2>/dev/null || true)" if [ -n "$origin" ]; then - identity="$(printf '%s' "$origin" | tr '[:upper:]' '[:lower:]')" + identity="$(printf '%s\n%s' "$origin" "$SOURCE_ROOT" | tr '[:upper:]' '[:lower:]')" name="$(basename "${origin%/}")" name="$(safe_id "$name")" else @@ -167,15 +179,28 @@ get_repository_id() { exclude_path() { path="$(git -C "$SOURCE_ROOT" rev-parse --path-format=absolute --git-path info/exclude 2>/dev/null || true)" if [ -z "$path" ]; then - path="$(git -C "$SOURCE_ROOT" rev-parse --git-path info/exclude)" - case "$path" in /*) ;; *) path="$SOURCE_ROOT/$path" ;; esac + git_directory="$(git -C "$SOURCE_ROOT" rev-parse --git-dir)" + case "$git_directory" in /*) ;; *) git_directory="$SOURCE_ROOT/$git_directory" ;; esac + path="$git_directory/info/exclude" fi printf '%s\n' "$path" } +assert_exclude_block_well_formed() { + path="$(exclude_path)" + [ -f "$path" ] || return 0 + start_count="$(awk -v marker="$EXCLUDE_START" '$0 == marker { count++ } END { print count + 0 }' "$path")" + end_count="$(awk -v marker="$EXCLUDE_END" '$0 == marker { count++ } END { print count + 0 }' "$path")" + if [ "$start_count" -ne "$end_count" ] || [ "$start_count" -gt 1 ]; then + echo "Refusing to rewrite malformed LearningVault markers in $path. Repair the marked block first." >&2 + exit 1 + fi +} + set_exclude_block() { present="$1" path="$(exclude_path)" + assert_exclude_block_well_formed mkdir -p "$(dirname "$path")" [ -f "$path" ] || : > "$path" temp="$path.learning-vault.$$" @@ -271,6 +296,7 @@ register_repository() { id="$1" assert_linked_install assert_state_untracked + assert_exclude_block_well_formed test_link_capability registration="$VAULT_ROOT/repositories/$id" mkdir -p "$registration" @@ -362,11 +388,32 @@ unregister_repository() { [ -L "$source" ] || [ ! -e "$source" ] || { echo "Cannot restore because a real source directory exists: $source" >&2; exit 1; } [ -d "$registration/$name" ] || { echo "Cannot restore because the vault copy is missing: $registration/$name" >&2; exit 1; } done + restored="" + rollback_restore() { + for restored_name in $restored; do + restored_source="$SOURCE_ROOT/$restored_name" + restored_destination="$registration/$restored_name" + [ ! -e "$restored_source" ] || [ -e "$restored_destination" ] || mv "$restored_source" "$restored_destination" + done + for restore_name in $STATE_DIRECTORIES; do + restore_source="$SOURCE_ROOT/$restore_name" + restore_destination="$registration/$restore_name" + [ -L "$restore_source" ] || [ -e "$restore_source" ] || [ ! -d "$restore_destination" ] || ln -s "$restore_destination" "$restore_source" + done + } + trap 'rollback_restore' HUP INT TERM for name in $STATE_DIRECTORIES; do source="$SOURCE_ROOT/$name" [ ! -L "$source" ] || rm -f "$source" - mv "$registration/$name" "$source" + if ! mv "$registration/$name" "$source"; then + rollback_restore + trap - HUP INT TERM + echo "Failed to restore $name; completed restores were returned to LearningVault." >&2 + exit 1 + fi + restored="$name $restored" done + trap - HUP INT TERM set_exclude_block false rm -f "$registration/VAULT.md" rmdir "$registration" 2>/dev/null || true From f51b64ec1daeb9196e4c896689b79321d68cc279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Gell=C3=A9r?= Date: Wed, 26 Aug 2026 11:44:20 +0200 Subject: [PATCH 7/7] docs: bump documentation to 1.5.0 Move LearningVault notes into the 1.5.0 release and pin packaged-install examples to that tag. Co-authored-by: Cursor --- CHANGELOG.md | 4 +++- MANIFEST.txt | 4 ++-- README.md | 4 ++-- docs/DESIGN_NOTES.md | 2 +- scripts/README.md | 10 +++++----- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b88ad..33f38a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -## Unreleased +## 1.5.0 + +Optional LearningVault storage for linked installations: repository-authored state can live in one local vault while framework files stay in `~/.agents`. Combined linked and vault installs write private harness exclusions to `.git/info/exclude` instead of shared `.gitignore`. ### Added diff --git a/MANIFEST.txt b/MANIFEST.txt index 2bfa2d0..1f91638 100644 --- a/MANIFEST.txt +++ b/MANIFEST.txt @@ -1,14 +1,14 @@ .gitattributes 40 .gitignore 324 AGENTS.md 2995 -CHANGELOG.md 32368 +CHANGELOG.md 32641 LICENSE 2213 README.md 13579 adoption/ADOPT.md 4177 adoption/README.md 2445 docs/AGENTIC_WORKFLOW_SANITY.md 10021 docs/ARCHITECTURE.md 9277 -docs/DESIGN_NOTES.md 48403 +docs/DESIGN_NOTES.md 48397 docs/EXAMPLE_WALKTHROUGH.md 3614 docs/EDUCATION_MODEL.md 5452 docs/INITIALIZE_LEARNING_FLOW.md 12199 diff --git a/README.md b/README.md index f1dbfc2..b128989 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,11 @@ For team or enterprise use, install a reviewed, versioned release. Substitute th ```sh curl -fsSL https://raw.githubusercontent.com/legrab/codebase-learning-flow/main/scripts/install.sh -o install.sh -sh install.sh --release v1.3.0 --profile minimal +sh install.sh --release v1.5.0 --profile minimal ``` ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/legrab/codebase-learning-flow/main/scripts/install.ps1))) -Release v1.3.0 -Profile Minimal +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/legrab/codebase-learning-flow/main/scripts/install.ps1))) -Release v1.5.0 -Profile Minimal ``` The installer verifies the release checksum before extraction and reports the resolved `Version:` and `Source:`. diff --git a/docs/DESIGN_NOTES.md b/docs/DESIGN_NOTES.md index 5b823dd..212fc28 100644 --- a/docs/DESIGN_NOTES.md +++ b/docs/DESIGN_NOTES.md @@ -4,7 +4,7 @@ The harness should keep a developer able to reason about a repository while collaborating with an agent, and let any learner use the same lightweight methods for a general subject. It should improve delivery, code and architecture understanding, domain reasoning, debugging, ownership growth, and conversational learning without making workflow administration or learning administration the primary activity. -## Unreleased: optional LearningVault storage +## v1.5: optional LearningVault storage The global install introduced in 1.4 deliberately left repository state in each repository. That remains the default and the ownership model. The missing diff --git a/scripts/README.md b/scripts/README.md index aa12e93..8a0d902 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -36,11 +36,11 @@ Under `--scope global` the steps that write repository state — `.local/`, `.gi ``` ```text -sh install.sh --release v1.3.0 --profile minimal +sh install.sh --release v1.5.0 --profile minimal ``` ```powershell -.\install.ps1 -Release v1.3.0 -Profile Minimal +.\install.ps1 -Release v1.5.0 -Profile Minimal ``` `--release`/`-Release` downloads the packaged artifact and `checksums.txt` @@ -58,7 +58,7 @@ Every install prints which trust boundary it used: ```text Codebase Learning Flow -Version: v1.3.0 +Version: v1.5.0 Source: packaged release (checksum verified) ``` @@ -190,8 +190,8 @@ Each root records `learning-flow/.install-scope`: ```text scope: linked -version: v1.4.0 -global-version: v1.4.0 +version: v1.5.0 +global-version: v1.5.0 ``` The installer is the reader. On a `linked` install it compares the version being written against the global installation's own and warns when they differ; `scripts/ci-install-test.sh` and `scripts/ci-release-test.sh` assert the two agree after a paired install. Installations predating this marker are treated as `repository`.