# planning-with-files: resolve active plan directory (PowerShell mirror).
#
# Resolution order matches scripts/resolve-plan-dir.sh:
# 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
# 2. .\.planning\.active_plan content
# 3. Newest .\.planning\<dir>\ by LastWriteTime
# 4. Empty (legacy fallback to .\task_plan.md handled by caller)
#
# v3.8.0 parity with the sh resolver: slug validation on every branch, the
# newest-dir scan requires task_plan.md inside the candidate (a sessions/ or
# artifacts/ dir must never win), and containment fails CLOSED when
# canonicalization fails. Only successful canonicalization can rule out a
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning"),
[switch]$CheckAmbiguity
)
$projectRoot = (Get-Location).Path
# Resolve-Path is lexical for Windows junctions: it can return the junction's
# spelling rather than the directory opened by the filesystem. Use a directory
# handle and GetFinalPathNameByHandleW on Windows so containment is decided from
# the object the kernel actually opened.
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($script:IsWindowsHost -and -not ("PwfResolverNative" -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfResolverNative {
private const uint FILE_SHARE_READ = 0x00000001;
private const uint FILE_SHARE_WRITE = 0x00000002;
private const uint FILE_SHARE_DELETE = 0x00000004;
private const uint OPEN_EXISTING = 3;
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
public static string FinalDirectoryPath(string path) {
// Win32 applies MAX_PATH to unprefixed names in a process that is not
// long-path aware (pwsh 7): a plan deeper than 260 characters resolved
// on Windows PowerShell 5.1 and sh but failed here. The extended
// prefix lifts the limit and needs the normalized form GetFullPath
// returns; the stripping below undoes the prefix again.
string full = Path.GetFullPath(path);
if (!full.StartsWith(@"\\?\", StringComparison.Ordinal))
full = full.StartsWith(@"\\", StringComparison.Ordinal)
? @"\\?\UNC\" + full.Substring(2) : @"\\?\" + full;
using (SafeFileHandle handle = CreateFileW(
full, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
function Get-FinalDirectoryPath {
param([string]$Path)
if ($script:IsWindowsHost) {
return [PwfResolverNative]::FinalDirectoryPath($Path)
}
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
}
function Test-FullyQualifiedLocalPath {
param([string]$Path)
if (-not $Path -or $Path.StartsWith('\\') -or $Path.StartsWith('//')) {
return $false
}
if ($script:IsWindowsHost) {
return $Path -match '^[A-Za-z]:[\\/]'
}
return [System.IO.Path]::IsPathRooted($Path)
}
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
# project resolves the parent's plan and never sees the nested one;
# PWF_PLAN_ROOT names the project root whose .planning must be used. Highest
# precedence: it overrides both the cwd default and the -PlanRoot argument
# (an adapter passing ".planning" is spelling out the cwd default, not
# overriding a user's deliberate pin). A pin that is not a directory fails
# CLOSED: the resolver emits nothing, so no caller can be handed the
# ambiguous cwd plan the pin was escaping (injection routes own the
# user-facing notice; stdout here is the data channel). Containment is then
# checked against the pinned root. Unset keeps legacy behavior unchanged.
if ($env:PWF_PLAN_ROOT) {
$pin = $env:PWF_PLAN_ROOT
if ((Test-FullyQualifiedLocalPath $pin) -and
(Test-Path -LiteralPath $pin -PathType Container)) {
$projectRoot = $pin
$PlanRoot = Join-Path $pin ".planning"
} else {
exit 0
}
}
# Same shape as the sh resolver's slug_is_valid: first char [A-Za-z0-9_],
# rest [A-Za-z0-9._-]. Blocks traversal tokens before any path is built.
function Test-ValidSlug {
param([string]$Name)
if (-not $Name) { return $false }
return $Name -match '^[A-Za-z0-9_][A-Za-z0-9._-]*$'
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to
# a path under the project root. A directory symlink/junction inside a valid
# slug pointing outside the workspace would otherwise let the hooks hash and
# inject an arbitrary file. Resolve-Path follows reparse points; we compare
# the real paths. Fails CLOSED on canonicalization failure, matching
# resolve-plan-dir.sh.
function Test-WithinRoot {
param([string]$Candidate)
try {
$rootReal = Get-FinalDirectoryPath $projectRoot
$candReal = Get-FinalDirectoryPath $Candidate
} catch {
return $false
}
if (-not $rootReal -or -not $candReal) { return $false }
$rootNorm = $rootReal.TrimEnd('\', '/')
$candNorm = $candReal.TrimEnd('\', '/')
if ($candNorm -eq $rootNorm) { return $true }
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
}
# A linked plan directory (symlink or junction) is never selectable: not by
# PLAN_ID, not by the pointer, not by the newest scan, and it never counts
# (#270). This is `[ -L ]` of resolve-plan-dir.sh and is_link of the Python
# twin: LinkType names symlinks and junctions only. The ReparsePoint
# attribute alone would also match OneDrive Files On-Demand placeholders,
# which every synced directory and file carries and which are not links to
# sh. The same predicate guards the pointer file below (#275).
function Test-LinkedDirectory {
param($PathOrItem)
if ($PathOrItem -is [string]) {
$item = Get-Item -LiteralPath $PathOrItem -Force -ErrorAction SilentlyContinue
} else {
$item = $PathOrItem
}
if (-not $item) { return $false }
return ([string]$item.LinkType) -in @('SymbolicLink', 'Junction')
}
$activeFile = Join-Path $PlanRoot ".active_plan"
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
# no directory, fails slug validation, or fails containment terminates
# resolution instead of falling through to .active_plan and newest-by-mtime:
# the fall-through let a one-character typo attest and inject a DIFFERENT plan
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
# The optional probe distinguishes ambiguity from a legacy-root fallback.
# Multiple named plans require PLAN_ID even without a sessions directory.
$planCount = 0
if (-not $env:PLAN_ID) {
if ((Test-Path -LiteralPath (Join-Path $PlanRoot "sessions") -PathType Container) -and
(Test-Path -LiteralPath (Join-Path $projectRoot "task_plan.md") -PathType Leaf)) {
$planCount = 1
}
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
# -Force: a Hidden plan directory counts on every other route.
foreach ($entry in (Get-ChildItem -LiteralPath $PlanRoot -Directory -Force -ErrorAction SilentlyContinue)) {
# A linked plan directory (symlink, junction) is not selectable and
# never counts, matching `[ -L ]` in resolve-plan-dir.sh (#270).
if (Test-LinkedDirectory $entry) { continue }
if ((Test-ValidSlug $entry.Name) -and
(Test-Path -LiteralPath (Join-Path $entry.FullName "task_plan.md") -PathType Leaf)) {
$planCount++
if ($planCount -gt 1) { break }
}
}
}
}
if ($CheckAmbiguity) {
if ($planCount -gt 1) { Write-Output "PWF_PLAN_AMBIGUOUS_V1" }
exit 0
}
if ($planCount -gt 1) { exit 0 }
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
}
exit 0
}
# Get-Item observes the link object even when its target is missing, unlike
# Test-Path which follows the target. An active pointer that is a directory or
# a symlink is an unsafe/ambiguous selector and must terminate resolution;
# falling through would silently select and expose the newest unrelated plan.
# LinkType, not the ReparsePoint attribute: OneDrive Files On-Demand marks
# every synced file as a reparse point, and such a pointer is a plain file
# to every other route (#275).
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
if ($activeItem) {
if ($activeItem.PSIsContainer -or (Test-LinkedDirectory $activeItem)) {
exit 0
}
# Get-Content -Raw returns $null for a zero-byte pointer; an empty pointer
# falls through like an invalid one instead of raising inside a caller.
$planId = "$(Get-Content -LiteralPath $activeFile -Raw -ErrorAction SilentlyContinue)".Trim()
if ($planId -and (Test-ValidSlug $planId)) {
$candidate = Join-Path $PlanRoot $planId
if ((Test-Path -LiteralPath $candidate -PathType Container) -and -not (Test-LinkedDirectory $candidate) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
}
}
# Literal paths throughout: a project path containing [ or ] is a wildcard to
# Test-Path and Get-ChildItem -Path, and a pattern that matches nothing turned a
# valid selection into an empty result.
if (Test-Path -LiteralPath $PlanRoot -PathType Container) {
$latest = Get-ChildItem -LiteralPath $PlanRoot -Directory -Force |
Where-Object { -not $_.Name.StartsWith('.') } |
Where-Object { -not (Test-LinkedDirectory $_) } |
Where-Object { Test-ValidSlug $_.Name } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
Where-Object { Test-WithinRoot $_.FullName } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($latest) {
Write-Output $latest.FullName
}
}
exit 0