scripts/internal/instruction-file-merge.ps1
|
Set-StrictMode -Version Latest # F-184 iteration 002 (T002; FR-012 / FR-013 / FR-015 / FR-018): the single packaged # source for the Specrew coordinator instruction section, plus the delimited # managed-section merge primitive. # # Host-neutral by construction (FR-015): nothing here knows about a specific host or # `agy`/Antigravity literals; callers pass the manifest-declared InstructionsFile path. # The merge replaces ONLY the delimited Specrew-owned section and preserves every other # byte of the target file (FR-012 / SC-012). # Size budget vs Codex's 32 KiB AGENTS.md concatenation cap (before-implement verdict # carry, 2026-06-17): hold the packaged fragment lean so a Specrew section atop a large # user AGENTS.md cannot push the root->cwd concatenation past the cap. $script:SpecrewCoordinatorFragmentMaxBytes = 4096 $script:SpecrewCoordinatorSectionName = 'coordinator' function Get-SpecrewInstructionBeginMarker { param([Parameter(Mandatory = $true)][string]$SectionName) return ('<!-- >>> specrew-managed {0} >>> -->' -f $SectionName) } function Get-SpecrewInstructionEndMarker { param([Parameter(Mandatory = $true)][string]$SectionName) return ('<!-- <<< specrew-managed {0} <<< -->' -f $SectionName) } function Get-SpecrewCoordinatorFragmentPath { # scripts/internal -> module root -> templates/coordinator-instructions.md $moduleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) return (Join-Path $moduleRoot 'templates\coordinator-instructions.md') } function Get-SpecrewCoordinatorFragment { # FR-018 single source: the packaged coordinator fragment content (trimmed). Both the # instruction-file merge (T002/T003) and the bootstrap (T004) read THIS function, so # the coordinator contract + the FR-013 guard text cannot drift between the two surfaces. $path = Get-SpecrewCoordinatorFragmentPath if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Packaged coordinator fragment not found at '$path'." } $fragment = (Get-Content -LiteralPath $path -Raw -Encoding UTF8).Trim() # Enforce the size budget at READ time (not only in tests): a packaged fragment that grows past # the cap would push a Specrew section atop a large user AGENTS.md past Codex's 32 KiB root->cwd # concatenation limit and silently regress startup. Fail loudly on the packaging regression. $fragmentBytes = [System.Text.Encoding]::UTF8.GetByteCount($fragment) if ($fragmentBytes -gt $script:SpecrewCoordinatorFragmentMaxBytes) { throw ("Packaged coordinator fragment is $fragmentBytes bytes, over the $($script:SpecrewCoordinatorFragmentMaxBytes)-byte budget (Codex AGENTS.md cap). Trim '$path'.") } return $fragment } function Merge-SpecrewManagedInstructionSection { # Pure: return $ExistingContent with the delimited managed section inserted (when # absent) or refreshed in place (when present), preserving every byte outside the # marker block. String-slicing, not regex replacement, so the preserved prefix/suffix # are byte-exact and replacement text needs no escaping. param( [AllowNull()][AllowEmptyString()][string]$ExistingContent, [Parameter(Mandatory = $true)][string]$ManagedContent, [string]$SectionName = $script:SpecrewCoordinatorSectionName ) $begin = Get-SpecrewInstructionBeginMarker -SectionName $SectionName $end = Get-SpecrewInstructionEndMarker -SectionName $SectionName $block = $begin + [Environment]::NewLine + $ManagedContent.Trim() + [Environment]::NewLine + $end $existing = if ($null -eq $ExistingContent) { '' } else { $ExistingContent } $beginIdx = $existing.IndexOf($begin, [System.StringComparison]::Ordinal) if ($beginIdx -ge 0) { $endIdx = $existing.IndexOf($end, $beginIdx, [System.StringComparison]::Ordinal) if ($endIdx -ge 0) { $endIdx += $end.Length $prefix = $existing.Substring(0, $beginIdx) $suffix = $existing.Substring($endIdx) return $prefix + $block + $suffix } } if ([string]::IsNullOrEmpty($existing)) { return $block + [Environment]::NewLine } # Append after preserved user content with a blank-line separator. $separator = if ($existing.EndsWith("`n")) { [Environment]::NewLine } else { [Environment]::NewLine + [Environment]::NewLine } return $existing + $separator + $block + [Environment]::NewLine } function Set-SpecrewInstructionFileSection { # Deploy/refresh the managed section into the InstructionsFile at $Path. Reads the # current file (or treats it as empty), merges, and writes atomically ONLY when the # content changed (idempotent: init/update/start-heal converge without rewriting an # already-current file). Returns { Path, Changed, Created }. param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$ManagedContent, [string]$SectionName = $script:SpecrewCoordinatorSectionName ) $existed = Test-Path -LiteralPath $Path -PathType Leaf $existing = if ($existed) { Get-Content -LiteralPath $Path -Raw -Encoding UTF8 } else { '' } if ($null -eq $existing) { $existing = '' } $merged = Merge-SpecrewManagedInstructionSection -ExistingContent $existing -ManagedContent $ManagedContent -SectionName $SectionName $changed = ($merged -ne $existing) if ($changed) { $dir = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($dir) -and -not (Test-Path -LiteralPath $dir -PathType Container)) { $null = New-Item -ItemType Directory -Path $dir -Force } $tempPath = '{0}.{1}.tmp' -f $Path, ([guid]::NewGuid().ToString('N')) $backupPath = $null try { [System.IO.File]::WriteAllText($tempPath, $merged, [System.Text.UTF8Encoding]::new($false)) if (Test-Path -LiteralPath $Path -PathType Leaf) { # Atomic in-place swap via the repo's established primitive (HandoverStore uses the same # [IO.File]::Replace = Win32 ReplaceFile): one call swaps in the new file and preserves the # destination's attributes. File.Replace REQUIRES a backup path (a bare $null marshals to an # empty string in PowerShell and throws), so use a transient backup and remove it below. $backupPath = '{0}.{1}.bak' -f $Path, ([guid]::NewGuid().ToString('N')) [System.IO.File]::Replace($tempPath, $Path, $backupPath) } else { [System.IO.File]::Move($tempPath, $Path) } } finally { foreach ($cleanup in @($tempPath, $backupPath)) { if ($cleanup -and (Test-Path -LiteralPath $cleanup -PathType Leaf)) { Remove-Item -LiteralPath $cleanup -Force -ErrorAction SilentlyContinue } } } } return [pscustomobject]@{ Path = $Path; Changed = $changed; Created = (-not $existed) } } |