diff --git a/local-docs/source-of-truth.md b/local-docs/source-of-truth.md index 52a06d8..d1c0cde 100644 --- a/local-docs/source-of-truth.md +++ b/local-docs/source-of-truth.md @@ -39,6 +39,7 @@ Status values: | 11 | Routing source reference (checklist prose deferred) | workflows/addition-intake.md | pin | policy-contracts.ps1 | consolidated | | 12 | Glossary terms | glossary.md | link | editorial convention | accepted | | 13 | Shared runtime-global prose | manifests/policy-contracts.json | pin | policy-contracts.ps1 | consolidated | +| 14 | Portable runtime home paths | scripts/common.ps1 home resolvers | pin | portable-home-paths.ps1 | consolidated | ## Intentional separations @@ -101,14 +102,14 @@ audits do not re-flag them. ## Status summary -Of the 13 fact families, 12 are consolidated or already canonical and 1 is +Of the 14 fact families, 13 are consolidated or already canonical and 1 is accepted as an intentional separation (cluster 12 glossary). The surface-specific update-together checklist prose (part of cluster 11) is deferred as editorial work. The consolidations are enforced mechanically: new manifests and doctor checks (`retired-skills`, -`model-tiers`, `generated-artifacts`, `source-of-truth`, `template-anchors`), -generators under `scripts/generators/`, policy-contract pins, git-derived -required files, and dynamic doctor dispatch. +`model-tiers`, `generated-artifacts`, `source-of-truth`, `template-anchors`, +`portable-home-paths`), generators under `scripts/generators/`, policy-contract +pins, git-derived required files, and dynamic doctor dispatch. Required files (cluster 4) use the git index as the source of repository membership. The architecturally mandatory entrypoints the old hand-written list diff --git a/manifests/doctor-checks.json b/manifests/doctor-checks.json index 7524319..aaa37ba 100644 --- a/manifests/doctor-checks.json +++ b/manifests/doctor-checks.json @@ -9,6 +9,7 @@ "manifest-boundaries.ps1", "model-tiers.ps1", "policy-contracts.ps1", + "portable-home-paths.ps1", "required-files.ps1", "restart-recovery.ps1", "retired-plugins.ps1", diff --git a/manifests/portable-files.toml b/manifests/portable-files.toml index 9b6c80a..b603a05 100644 --- a/manifests/portable-files.toml +++ b/manifests/portable-files.toml @@ -86,8 +86,8 @@ derived_skills = [ "write-a-skill", ] -# These reviewer agents are maintained explicitly because their Claude -# frontmatter and Codex read-only sandbox contracts are platform-specific. +# No Claude agent is maintained as a separate source. Every Claude agent +# derives from codex/agents/*.toml at install time; see derived_agents below. agents = [] # Other Claude agents derive from codex/agents/.toml at install time. diff --git a/scripts/doctor/checks/claude.ps1 b/scripts/doctor/checks/claude.ps1 index b0f188e..d71b639 100644 --- a/scripts/doctor/checks/claude.ps1 +++ b/scripts/doctor/checks/claude.ps1 @@ -159,4 +159,12 @@ else { $problems.Add("claude derived agent missing frontmatter entry: $agent") } } + + # Reverse binding: every frontmatter entry must be a declared derived + # agent, so an orphan left behind after removing a derived agent fails. + foreach ($agent in $script:ClaudeDerivedAgentFrontmatter.Keys) { + if ($manifestClaudeDerivedAgents -notcontains $agent) { + $problems.Add("claude frontmatter entry is not a declared derived agent: $agent") + } + } } diff --git a/scripts/doctor/checks/portable-home-paths.ps1 b/scripts/doctor/checks/portable-home-paths.ps1 new file mode 100644 index 0000000..1b9f4aa --- /dev/null +++ b/scripts/doctor/checks/portable-home-paths.ps1 @@ -0,0 +1,146 @@ +# Home paths are executable truth in scripts/common.ps1 (Get-CodexHome, +# Get-AgentsHome, Get-ClaudeHome). The manifest, launchers, operator docs, and +# focused workflow docs repeat those defaults for different audiences. This +# check derives every expected copy from common.ps1 so a default change cannot +# leave an accepted runtime route or current instruction stale. +$paths = @{ + common = Join-Path $repoRoot "scripts\common.ps1" + manifest = Join-Path $repoRoot "manifests\portable-files.toml" + portableWorkflow = Join-Path $repoRoot "workflows\portable-config.md" + claudeWorkflow = Join-Path $repoRoot "workflows\claude-config.md" + whichLlmWorkflow = Join-Path $repoRoot "workflows\which-llm-skill.md" + readme = Join-Path $repoRoot "README.md" + contributing = Join-Path $repoRoot "CONTRIBUTING.md" + doctor = Join-Path $repoRoot "scripts\doctor.ps1" + hookPowerShell = Join-Path $repoRoot "codex\hooks\portable_guard.ps1" + hookShell = Join-Path $repoRoot "codex\hooks\portable_guard.sh" + hooksJson = Join-Path $repoRoot "codex\hooks.json" + hooksReadme = Join-Path $repoRoot "codex\hooks\README.md" +} + +$missing = @($paths.GetEnumerator() | Where-Object { -not (Test-Path -LiteralPath $_.Value -PathType Leaf) }) +if ($missing.Count -gt 0) { + foreach ($entry in $missing) { + $problems.Add("missing $($entry.Value) for home path check") + } +} +else { + function Get-PortableManifestString { + param($Manifest, $Section, $Key) + $sectionValue = Get-PortableJsonProperty -Object $Manifest -Name $Section + return Get-PortableJsonProperty -Object $sectionValue -Name $Key + } + + function Assert-Equals { + param($Actual, $Expected, [string]$Label) + if ($null -eq $Actual) { + $problems.Add("$Label is missing") + } + elseif ($Actual -cne $Expected) { + $problems.Add("$Label documents '$Actual' but expected '$Expected'") + } + } + + function Normalize-PortableWhitespace { + param([string]$Text) + return [regex]::Replace($Text, '\s+', ' ').Trim() + } + + function Assert-Contains { + param([string]$Text, [string]$Expected, [string]$Label) + if ($Text.IndexOf($Expected, [System.StringComparison]::Ordinal) -lt 0) { + $problems.Add("$Label is missing or stale: $Expected") + } + } + + function Assert-NormalizedContains { + param([string]$Text, [string]$Expected, [string]$Label) + $normalizedText = Normalize-PortableWhitespace $Text + $normalizedExpected = Normalize-PortableWhitespace $Expected + if ($normalizedText.IndexOf($normalizedExpected, [System.StringComparison]::Ordinal) -lt 0) { + $problems.Add("$Label is missing or stale: $normalizedExpected") + } + } + + function Assert-MatchCount { + param([string]$Text, [string]$Expected, [int]$Count, [string]$Label) + $actual = [regex]::Matches($Text, [regex]::Escape($Expected)).Count + if ($actual -ne $Count) { + $problems.Add("$Label expected $Count copies of '$Expected' but found $actual") + } + } + + $common = Get-Content -Raw -LiteralPath $paths.common + $codexMatch = [regex]::Match($common, 'Join-Path \$env:USERPROFILE "([^"]+)"') + $homeMatches = [regex]::Matches($common, 'Join-Path \$HOME "([^"]+)"') + if (-not $codexMatch.Success -or $homeMatches.Count -lt 2) { + $problems.Add("could not extract home directory defaults from common.ps1") + } + else { + $defaults = @{ + codex = $codexMatch.Groups[1].Value + agents = $homeMatches[0].Groups[1].Value + claude = $homeMatches[1].Groups[1].Value + } + $manifest = (Get-PortableGeneratedData).manifest + + $codexHome = '$CODEX_HOME or %USERPROFILE%\' + $defaults.codex + $agentsHome = '$HOME\' + $defaults.agents + $claudeHome = '$HOME\' + $defaults.claude + $codexTilde = '~/' + $defaults.codex + $claudeTilde = '~/' + $defaults.claude + $agentsSlash = '$HOME/' + $defaults.agents + $claudeSlash = '$HOME/' + $defaults.claude + + Assert-Equals (Get-PortableManifestString $manifest "codex" "home") $codexHome "portable manifest [codex].home" + Assert-Equals (Get-PortableManifestString $manifest "agents" "home") $agentsHome "portable manifest [agents].home" + Assert-Equals (Get-PortableManifestString $manifest "agents" "skills_dir") ($agentsHome + "\skills") "portable manifest [agents].skills_dir" + Assert-Equals (Get-PortableManifestString $manifest "claude" "home") $claudeHome "portable manifest [claude].home" + Assert-Equals (Get-PortableManifestString $manifest "claude" "skills_dir") ($claudeHome + "\skills") "portable manifest [claude].skills_dir" + Assert-Equals (Get-PortableManifestString $manifest "claude" "agents_dir") ($claudeHome + "\agents") "portable manifest [claude].agents_dir" + + $portableWorkflow = Get-Content -Raw -LiteralPath $paths.portableWorkflow + Assert-Contains $portableWorkflow ('- `-CodexHome`, then `$env:CODEX_HOME`, then `%USERPROFILE%\' + $defaults.codex + '`;') "portable-config Codex resolution" + Assert-Contains $portableWorkflow ('- `-AgentsHome`, then `$HOME\' + $defaults.agents + '`;') "portable-config agents resolution" + Assert-Contains $portableWorkflow ('- `-ClaudeHome`, then `$HOME\' + $defaults.claude + '`.') "portable-config Claude resolution" + + $readme = Get-Content -Raw -LiteralPath $paths.readme + $readmeResolution = 'Scripts use `-CodexHome`, then `$env:CODEX_HOME`, then `%USERPROFILE%\' + $defaults.codex + '`; `-AgentsHome`, then `$HOME\' + $defaults.agents + '`; and `-ClaudeHome`, then `$HOME\' + $defaults.claude + '`.' + Assert-NormalizedContains $readme $readmeResolution "README home resolution" + Assert-Contains $readme ($codexTilde + "/AGENTS.md") "README Codex install root" + Assert-Contains $readme ($claudeTilde + "/CLAUDE.md") "README Claude install root" + Assert-Contains $readme ($agentsSlash + "/skills") "README agents skill root" + Assert-Contains $readme ($claudeSlash + "/skills") "README Claude skill root" + + $contributing = Get-Content -Raw -LiteralPath $paths.contributing + Assert-Contains $contributing ($agentsSlash + "/skills") "CONTRIBUTING agents skill root" + + $claudeWorkflow = Get-Content -Raw -LiteralPath $paths.claudeWorkflow + Assert-Contains $claudeWorkflow ($claudeSlash + "/CLAUDE.md") "Claude workflow instruction root" + Assert-Contains $claudeWorkflow $claudeSlash "Claude workflow default root" + + $whichLlmWorkflow = Get-Content -Raw -LiteralPath $paths.whichLlmWorkflow + Assert-Contains $whichLlmWorkflow ($agentsHome + "\skills\which-llm\pick.py") "which-llm installed pick path" + Assert-Contains $whichLlmWorkflow ($agentsHome + "\skills\which-llm\query.py") "which-llm installed query path" + + $doctor = Get-Content -Raw -LiteralPath $paths.doctor + Assert-Contains $doctor ('$liveHome = Join-Path $env:USERPROFILE "' + $defaults.codex + '"') "doctor Codex fallback" + Assert-Contains $doctor ('$agentsHomePath = Join-Path $HOME "' + $defaults.agents + '"') "doctor agents fallback" + Assert-Contains $doctor ('$claudeHomePath = Join-Path $HOME "' + $defaults.claude + '"') "doctor Claude fallback" + + $hookPowerShell = Get-Content -Raw -LiteralPath $paths.hookPowerShell + Assert-Contains $hookPowerShell ('$codexHome = Join-Path $env:USERPROFILE "' + $defaults.codex + '"') "PowerShell hook Codex fallback" + + $hookShell = Get-Content -Raw -LiteralPath $paths.hookShell + Assert-Contains $hookShell ('codex_home=$HOME/' + $defaults.codex) "shell hook Codex fallback" + + $hooksJson = Get-Content -Raw -LiteralPath $paths.hooksJson + $hookShellDefault = '${CODEX_HOME:-$HOME/' + $defaults.codex + '}/hooks/portable_guard.sh' + $hookWindowsDefault = "Join-Path `$env:USERPROFILE '$($defaults.codex)'" + Assert-MatchCount $hooksJson $hookShellDefault 2 "hook JSON shell Codex fallback" + Assert-MatchCount $hooksJson $hookWindowsDefault 2 "hook JSON Windows Codex fallback" + + $hooksReadme = Get-Content -Raw -LiteralPath $paths.hooksReadme + Assert-Contains $hooksReadme $codexTilde "hooks README Codex default" + } +} diff --git a/scripts/doctor/checks/source-of-truth.ps1 b/scripts/doctor/checks/source-of-truth.ps1 index 2197154..6a355bf 100644 --- a/scripts/doctor/checks/source-of-truth.ps1 +++ b/scripts/doctor/checks/source-of-truth.ps1 @@ -97,7 +97,7 @@ else { } } - foreach ($expected in 1..13) { + foreach ($expected in 1..14) { if ($seenIds -notcontains "$expected") { $problems.Add("source-of-truth register is missing cluster row: $expected") }