# session-scaffold.ps1 -- scaffold 1 logic-session folder + _context skeleton (M1, S149). # TAILOR tu hub AI_INFRA/scripts/session_scaffold.py (169 dong) -> SE PowerShell, 2-mode split @S146. # # MODE (exactly one): # -New : allocate a NEW logic-session = max+1 (session-start ONLY). Optional -N pins an # explicit id; if that id already exists (folder OR _context) -> COLLISION # (exit 4, writes NOTHING). # -Ensure : idempotent heal of the CURRENT session (pause/tiep). _context missing -> create; # _context present -> exit 0 NO-OP (mtime preserved). Optional -N pins the id; # default = max existing session. # # INVARIANTS: # - ts-moc = `git log -1 --format=%cI` of RepoRoot HEAD (git-committer). NO wall-clock: git # unavailable -> exit 3 (refuse Get-Date). Deliberate DEVIATION from hub tagged-FALLBACK -- # spec M1 line 33 "khong wall-clock"; ts feeds C10 LATENESS measurement, must be deterministic. # - NEVER overwrite an existing _context (ANY mode) -- hard guard in Write-Context (exit 5). # - Pure-ASCII source (gotcha #30/#37 = PS 5.1 diacritics parser-fail). Vietnamese lives ONLY # in the external template .md, never in this .ps1. # # -RepoRoot = fault-inject seam: point at a scratch tree to prove teeth, not just happy-path. # Exit codes: 0 ok/no-op | 2 usage | 3 git-unavailable | 4 collision | 5 defensive-overwrite-block. param( [switch]$New, [switch]$Ensure, [int]$N, [string]$RepoRoot = "$PSScriptRoot\.." ) $ErrorActionPreference = 'Stop' # --- helpers --- function Fail { param([string]$Msg, [int]$Code) Write-Output "session-scaffold: ERROR: $Msg" exit $Code } function Invoke-Git { # Run git with stderr discarded; return @{ Out; Code }. EAP=Continue so a non-repo tree # (git exit 128) does NOT throw under the script-wide Stop preference. param([string[]]$GitArgs) $old = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $out = & git @GitArgs 2>$null return @{ Out = (($out | Out-String)).Trim(); Code = $LASTEXITCODE } } finally { $ErrorActionPreference = $old } } function Rel { param([string]$Path, [string]$Root) if ($Path.StartsWith($Root, [StringComparison]::OrdinalIgnoreCase)) { return $Path.Substring($Root.Length).TrimStart('\', '/') } return $Path } function Write-Context { # Render template -> _context, UTF-8 no-BOM + LF. HARD guard: never overwrite an existing # _context (any mode) -- this is the durable owner-evidence file. param([string]$TemplatePath, [string]$OutPath, [int]$SessionN, [string]$Ts, [string]$Head) if (Test-Path -LiteralPath $OutPath) { Fail "REFUSE-OVERWRITE: $OutPath exists (never overwrite _context)" 5 } $tpl = [System.IO.File]::ReadAllText($TemplatePath, [System.Text.Encoding]::UTF8) $rendered = $tpl.Replace('{{N}}', "$SessionN").Replace('{{TS}}', $Ts).Replace('{{HEAD}}', $Head) $rendered = $rendered -replace "`r`n", "`n" $outDir = Split-Path -Parent $OutPath if (-not (Test-Path -LiteralPath $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } $enc = New-Object System.Text.UTF8Encoding($false) # $false = emit NO byte-order-mark [System.IO.File]::WriteAllText($OutPath, $rendered, $enc) } # --- validate mode (exactly one) --- if ($New -and $Ensure) { Fail "specify exactly one of -New / -Ensure (both given)" 2 } if (-not $New -and -not $Ensure) { Fail "specify exactly one of -New / -Ensure (none given)" 2 } $explicitN = $PSBoundParameters.ContainsKey('N') if ($explicitN -and $N -lt 0) { Fail "-N must be a non-negative integer (got $N)" 2 } # --- resolve root + template --- if (-not (Test-Path -LiteralPath $RepoRoot)) { Fail "RepoRoot not found: $RepoRoot" 2 } $root = (Resolve-Path -LiteralPath $RepoRoot).Path $templatePath = Join-Path $root '.claude\templates\session-context-template.md' if (-not (Test-Path -LiteralPath $templatePath)) { Fail "template not found: $templatePath" 2 } $sessionsRoot = Join-Path $root '.claude\sessions' # --- compute max existing session- --- $maxN = 0 if (Test-Path -LiteralPath $sessionsRoot) { Get-ChildItem -LiteralPath $sessionsRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object { if ($_.Name -match '^session-(\d+)$') { $k = [int]$Matches[1] if ($k -gt $maxN) { $maxN = $k } } } } # --- resolve target N per mode --- if ($New) { if ($explicitN) { $targetN = $N } else { $targetN = $maxN + 1 } } else { if ($explicitN) { $targetN = $N } else { if ($maxN -lt 1) { Fail "-Ensure: no existing session to heal (run -New first)" 2 } $targetN = $maxN } } $sessionDir = Join-Path $sessionsRoot "session-$targetN" $contextPath = Join-Path $sessionDir "_context-s-$targetN.md" $folderExists = Test-Path -LiteralPath $sessionDir $contextExists = Test-Path -LiteralPath $contextPath # --- ts-moc from git HEAD (NO wall-clock; refuse if git down) --- $r1 = Invoke-Git @('-C', $root, 'log', '-1', '--format=%cI') $r2 = Invoke-Git @('-C', $root, 'log', '-1', '--format=%h') if ($r1.Code -ne 0 -or $r2.Code -ne 0) { Fail "git HEAD unavailable at $root -- refusing wall-clock fallback (spec M1: no wall-clock)" 3 } $ts = $r1.Out $head = $r2.Out if ([string]::IsNullOrWhiteSpace($ts)) { Fail "git returned empty committer-ts -- refusing wall-clock fallback" 3 } # --- mode: New (allocate; collision => write NOTHING) --- if ($New) { if ($folderExists -or $contextExists) { $why = if ($contextExists) { "_context exists" } else { "session folder exists" } Write-Output "session-scaffold: COLLISION session-$targetN ($why) -- writing NOTHING (exit 4)." exit 4 } Write-Context -TemplatePath $templatePath -OutPath $contextPath -SessionN $targetN -Ts $ts -Head $head Write-Output "session-scaffold: [New] created session-$targetN (max was $maxN)" Write-Output " folder = $(Rel $sessionDir $root)" Write-Output " context = $(Rel $contextPath $root)" Write-Output " ts-moc = $ts (HEAD $head)" exit 0 } # --- mode: Ensure (idempotent heal) --- if ($contextExists) { Write-Output "session-scaffold: [Ensure] session-$targetN _context present -- NO-OP (exit 0, mtime preserved)." exit 0 } Write-Context -TemplatePath $templatePath -OutPath $contextPath -SessionN $targetN -Ts $ts -Head $head $note = if ($folderExists) { "healed (folder existed, _context missing)" } else { "created (folder was missing)" } Write-Output "session-scaffold: [Ensure] session-$targetN $note" Write-Output " context = $(Rel $contextPath $root)" Write-Output " ts-moc = $ts (HEAD $head)" exit 0