diff --git a/.github/workflows/windows-full-ci.yml b/.github/workflows/windows-full-ci.yml
index cdebe58..4f51b8a 100644
--- a/.github/workflows/windows-full-ci.yml
+++ b/.github/workflows/windows-full-ci.yml
@@ -13,8 +13,12 @@ on:
- "scripts/test-fetch-herdr-annotate.*"
- "scripts/test-fetch-plannotator-tui.*"
- "scripts/test-herdr-windows-plugin.ps1"
+ - "scripts/test-herdr-windows-full-plugin.ps1"
+ - "scripts/test-herdr-windows-full-pane.ps1"
+ - "scripts/test-herdr-windows-full-paths.ps1"
- "scripts/test-http-server.py"
- "scripts/test-windows-full-manifest.py"
+ - "windows-full/**"
- ".github/workflows/windows-full-ci.yml"
merge_group:
@@ -53,6 +57,12 @@ jobs:
plannotator-tui-source/herdr/herdr-plugin.toml
- name: pinned Herdr link and list
run: ./scripts/test-herdr-windows-plugin.ps1
+ - name: pinned Herdr windows-full acceptance and 0.8.2 rejection
+ run: ./scripts/test-herdr-windows-full-plugin.ps1
+ - name: windows-full review pane render, quit and teardown
+ run: ./scripts/test-herdr-windows-full-pane.ps1
+ - name: windows-full path matrix
+ run: ./scripts/test-herdr-windows-full-paths.ps1
unix-regression:
strategy:
diff --git a/.gitignore b/.gitignore
index c074431..2a86054 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
.DS_Store
bin/*
!bin/.gitkeep
+windows-full/bin/*
+!windows-full/bin/.gitkeep
/rust/target/
diff --git a/README.md b/README.md
index 910dd18..584abfa 100644
--- a/README.md
+++ b/README.md
@@ -23,16 +23,16 @@ There is no runtime to install. Both installs download a small prebuilt `herdr-a
On Linux, install `wl-clipboard`, `xclip`, or `xsel` for clipboard access.
-On Windows, native Herdr plugin support is preview/best-effort. Clipboard access uses PowerShell; no extra clipboard package is required. The install, keybinding, configuration check, reload, and use instructions below also apply on Windows.
+On Windows, native Herdr plugin support is preview/best-effort. Lite installs on Herdr 0.8.0 or later; Full installs through the Windows Full variant below, which needs Herdr 0.9.0 or later. Clipboard access uses PowerShell; no extra clipboard package is required. The install, keybinding, configuration check, reload, and use instructions below also apply on Windows.
## Install
-Pick one. Installing the other later just swaps it (same plugin id). An install stays on the commit it came from; run the same command again to move to the current release.
+Pick one. Installing another later just swaps it (same plugin id). An install stays on the commit it came from; run the same command again to move to the current release.
**Full:** annotate terminal text, review documents and agent replies, send feedback to the agent.
-Wraps [Plannotator TUI](https://github.com/plannotator/plannotator-tui) (macOS and Linux today). [Demo](https://x.com/plannotator/status/2093419561077154287)
+Wraps [Plannotator TUI](https://github.com/plannotator/plannotator-tui) (macOS and Linux; Windows via the windows-full variant below). [Demo](https://x.com/plannotator/status/2093419561077154287)
```sh
herdr plugin install plannotator/herdr-annotate
@@ -50,6 +50,16 @@ herdr plugin install plannotator/herdr-annotate/lite
+**Windows Full (preview):** the Full install on Windows, opt-in. Requires Herdr 0.9.0 or later on Windows.
+
+```sh
+herdr plugin install plannotator/herdr-annotate/windows-full
+```
+
+It stages `plannotator-tui` natively, so no launcher sits between Herdr and the TUI. It shares the same native runtime as the Lite and Full installs, so annotations and archives stay in one place.
+
+This is a preview. The pane and path handling were verified on Windows 11 x86_64 ([PR #49](https://github.com/plannotator/herdr-annotate/pull/49), [issue #43](https://github.com/plannotator/herdr-annotate/issues/43)). Native ARM64 is untested. Report results on [issue #43](https://github.com/plannotator/herdr-annotate/issues/43).
+
> **Required.** Bind the keys in Herdr's config.
diff --git a/scripts/fetch-herdr-annotate.ps1 b/scripts/fetch-herdr-annotate.ps1
index 37a6a06..08cdc3f 100644
--- a/scripts/fetch-herdr-annotate.ps1
+++ b/scripts/fetch-herdr-annotate.ps1
@@ -1,22 +1,31 @@
$ErrorActionPreference = "Stop"
-Set-Location (Join-Path $PSScriptRoot "..")
+# Anchored on the script's own location rather than the current directory, and every path
+# below is absolute and literal. A checkout path may legitimately contain square brackets --
+# the Windows acceptance path matrix requires it -- and PowerShell reads those as wildcards in
+# any -Path parameter. Worse, once the current directory contains them, it is stored escaped,
+# so even -LiteralPath with a relative path resolves to a name with backticks in it and is not
+# found. Not depending on the current directory at all is what makes that whole class go away.
+# .NET rather than Split-Path: an extended-length \\?\ root is a path matrix row, and
+# Split-Path cannot parse one -- it reports a null drive and returns nothing.
+$root = [System.IO.Path]::GetDirectoryName($PSScriptRoot)
-$versionContents = Get-Content -LiteralPath "herdr-annotate.version" -Raw
+$versionContents = Get-Content -LiteralPath ([System.IO.Path]::Combine($root, "herdr-annotate.version")) -Raw
$version = if ($null -eq $versionContents) { "" } else { [string]$versionContents }
$version = $version.Trim()
if (-not $version) { throw "herdr-annotate.version is empty" }
-New-Item -ItemType Directory -Force "bin" | Out-Null
-$destination = Join-Path "bin" "herdr-annotate.exe"
-$stamp = Join-Path "bin" "herdr-annotate.version"
+$binDirectory = [System.IO.Path]::Combine($root, "bin")
+[System.IO.Directory]::CreateDirectory($binDirectory) | Out-Null
+$destination = [System.IO.Path]::Combine($binDirectory, "herdr-annotate.exe")
+$stamp = [System.IO.Path]::Combine($binDirectory, "herdr-annotate.version")
$installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) { ([string](Get-Content -LiteralPath $stamp -Raw)).Trim() } else { "" }
-if ((Test-Path $destination) -and $installed -eq $version -and -not $env:HERDR_ANNOTATE_BIN) {
+if ((Test-Path -LiteralPath $destination) -and $installed -eq $version -and -not $env:HERDR_ANNOTATE_BIN) {
Write-Output "herdr-annotate $version already installed"
exit 0
}
if ($env:HERDR_ANNOTATE_BIN) {
- if (-not (Test-Path $env:HERDR_ANNOTATE_BIN -PathType Leaf)) {
+ if (-not (Test-Path -LiteralPath $env:HERDR_ANNOTATE_BIN -PathType Leaf)) {
throw "HERDR_ANNOTATE_BIN is not a file: $env:HERDR_ANNOTATE_BIN"
}
Copy-Item -Force -LiteralPath $env:HERDR_ANNOTATE_BIN -Destination "$destination.tmp"
@@ -35,20 +44,20 @@ $target = switch ($architecture) {
$asset = "herdr-annotate-$target.exe"
$base = "https://github.com/plannotator/herdr-annotate/releases/download/rust-lite-v$version"
$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("herdr-annotate-" + [guid]::NewGuid())
-New-Item -ItemType Directory $temporary | Out-Null
+[System.IO.Directory]::CreateDirectory($temporary) | Out-Null
try {
Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile (Join-Path $temporary $asset)
Invoke-WebRequest -UseBasicParsing "$base/SHA256SUMS" -OutFile (Join-Path $temporary "SHA256SUMS")
- $line = Get-Content (Join-Path $temporary "SHA256SUMS") | Where-Object { $_ -match "\s$([regex]::Escape($asset))$" } | Select-Object -First 1
+ $line = Get-Content -LiteralPath (Join-Path $temporary "SHA256SUMS") | Where-Object { $_ -match "\s$([regex]::Escape($asset))$" } | Select-Object -First 1
if (-not $line) { throw "$asset is not listed in $base/SHA256SUMS" }
$expected = ($line -split "\s+")[0].ToLowerInvariant()
- $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $temporary $asset)).Hash.ToLowerInvariant()
+ $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $temporary $asset)).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw "sha256 mismatch for ${asset}: expected $expected, got $actual" }
- Copy-Item -Force (Join-Path $temporary $asset) "$destination.tmp"
+ Copy-Item -Force -LiteralPath (Join-Path $temporary $asset) -Destination "$destination.tmp"
Move-Item -Force -LiteralPath "$destination.tmp" -Destination $destination
Set-Content -LiteralPath $stamp -NoNewline -Value $version
Write-Output "installed herdr-annotate $version ($target)"
}
finally {
- Remove-Item -Recurse -Force $temporary -ErrorAction SilentlyContinue
+ Remove-Item -Recurse -Force -LiteralPath $temporary -ErrorAction SilentlyContinue
}
diff --git a/scripts/fetch-plannotator-tui.ps1 b/scripts/fetch-plannotator-tui.ps1
index 8edbeb4..79a4ac8 100644
--- a/scripts/fetch-plannotator-tui.ps1
+++ b/scripts/fetch-plannotator-tui.ps1
@@ -1,18 +1,42 @@
+# -DestinationDirectory stages the binary and its stamp somewhere other than the repository's
+# own bin/, which is how the windows-full variant keeps its copy beside its manifest. Omitted,
+# the destination is unchanged, so every existing caller behaves exactly as before. The pin is
+# read from the repository root either way: there is one release pin, not one per variant.
+param([string]$DestinationDirectory)
+
$ErrorActionPreference = "Stop"
-Set-Location (Join-Path $PSScriptRoot "..")
+# Anchored on the script's own location rather than the current directory, and every path
+# below is absolute and literal. A checkout path may legitimately contain square brackets --
+# the Windows acceptance path matrix requires it -- and PowerShell reads those as wildcards in
+# any -Path parameter. Worse, once the current directory contains them, it is stored escaped,
+# so even -LiteralPath with a relative path resolves to a name with backticks in it and is not
+# found. Not depending on the current directory at all is what makes that whole class go away.
+# .NET rather than Split-Path: an extended-length \\?\ root is a path matrix row, and
+# Split-Path cannot parse one -- it reports a null drive and returns nothing.
+$root = [System.IO.Path]::GetDirectoryName($PSScriptRoot)
-$versionContents = Get-Content -LiteralPath "plannotator-tui.version" -Raw
+$versionContents = Get-Content -LiteralPath ([System.IO.Path]::Combine($root, "plannotator-tui.version")) -Raw
$version = if ($null -eq $versionContents) { "" } else { [string]$versionContents }
$version = $version.Trim()
if (-not $version) { throw "plannotator-tui.version is empty" }
-$destinationDirectory = Join-Path (Get-Location).Path "bin"
-$destination = Join-Path $destinationDirectory "plannotator-tui.exe"
-$stamp = Join-Path $destinationDirectory "plannotator-tui.version"
-New-Item -ItemType Directory -Force $destinationDirectory | Out-Null
+$destinationDirectory = if ([string]::IsNullOrWhiteSpace($DestinationDirectory)) {
+ [System.IO.Path]::Combine($root, "bin")
+} else {
+ $DestinationDirectory
+}
+$destination = [System.IO.Path]::Combine($destinationDirectory, "plannotator-tui.exe")
+$stamp = [System.IO.Path]::Combine($destinationDirectory, "plannotator-tui.version")
+# .NET rather than New-Item for the same reason: the destination is an absolute path that may
+# contain brackets, and New-Item -Path would treat them as a wildcard.
+[System.IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null
$localOverride = [Environment]::GetEnvironmentVariable("PLANNOTATOR_TUI_BIN", "Process")
-$hasLocalOverride = $null -ne $localOverride
+# Empty counts as absent, as it does for PLANNOTATOR_TUI_RELEASE_BASE below and in the
+# Unix fetcher: a caller clearing the name through an API that binds $null as
+# "" leaves it defined, and an empty override would otherwise reach the "is not a file"
+# throw, which sits outside the warn-and-exit-zero path and fails the build outright.
+$hasLocalOverride = -not [string]::IsNullOrWhiteSpace($localOverride)
$installed = if (Test-Path -LiteralPath $stamp -PathType Leaf) {
([string](Get-Content -LiteralPath $stamp -Raw)).Trim()
} else {
@@ -28,9 +52,9 @@ if ((Test-Path -LiteralPath $destination -PathType Leaf) -and
function Install-PlannotatorTui {
param([Parameter(Mandatory = $true)][string]$Source)
- $candidate = Join-Path $destinationDirectory ("plannotator-tui-" + [guid]::NewGuid() + ".tmp")
- $backup = Join-Path $destinationDirectory ("plannotator-tui-" + [guid]::NewGuid() + ".bak")
- $stampBackup = Join-Path $destinationDirectory ("plannotator-tui-version-" + [guid]::NewGuid() + ".bak")
+ $candidate = [System.IO.Path]::Combine($destinationDirectory, ("plannotator-tui-" + [guid]::NewGuid() + ".tmp"))
+ $backup = [System.IO.Path]::Combine($destinationDirectory, ("plannotator-tui-" + [guid]::NewGuid() + ".bak"))
+ $stampBackup = [System.IO.Path]::Combine($destinationDirectory, ("plannotator-tui-version-" + [guid]::NewGuid() + ".bak"))
$hadDestination = Test-Path -LiteralPath $destination -PathType Leaf
$hadStamp = Test-Path -LiteralPath $stamp -PathType Leaf
$replacementCompleted = $false
@@ -112,8 +136,12 @@ try {
"PLANNOTATOR_TUI_RELEASE_BASE",
"Process"
)
- # PLANNOTATOR_TUI_RELEASE_BASE is a test-only seam for a loopback fixture server.
- $base = if ($null -ne $releaseBaseOverride) {
+ # PLANNOTATOR_TUI_RELEASE_BASE is a test-only seam for a loopback fixture server. An empty
+ # value counts as absent: a caller clearing it through an API that binds $null as "" would
+ # otherwise leave the name defined, and an empty base builds a URL with no host at all --
+ # which surfaces as "invalid URI" long after the mistake, through the warn-and-exit-zero
+ # contract that hides it.
+ $base = if (-not [string]::IsNullOrWhiteSpace($releaseBaseOverride)) {
$releaseBaseOverride.TrimEnd([char]"/")
} else {
"https://github.com/plannotator/plannotator-tui/releases/download/v$version"
@@ -121,7 +149,7 @@ try {
$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("plannotator-tui-" + [guid]::NewGuid())
try {
- New-Item -ItemType Directory $temporary | Out-Null
+ [System.IO.Directory]::CreateDirectory($temporary) | Out-Null
$downloadedAsset = Join-Path $temporary $asset
$checksumFile = Join-Path $temporary "SHA256SUMS"
Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile $downloadedAsset
@@ -155,3 +183,8 @@ try {
)
exit 0
}
+
+# Explicit rather than falling off the end: the windows-full wrapper propagates
+# $LASTEXITCODE, which a script ending without an exit leaves undefined or at whatever
+# the last native command set.
+exit 0
diff --git a/scripts/test-herdr-windows-full-pane.ps1 b/scripts/test-herdr-windows-full-pane.ps1
new file mode 100644
index 0000000..9c84388
--- /dev/null
+++ b/scripts/test-herdr-windows-full-pane.ps1
@@ -0,0 +1,304 @@
+# The Windows Full review pane, end to end, against a pinned Herdr release in an isolated
+# config/state/socket so the machine's own server and session are never touched.
+#
+# What this proves that no manifest or link test can: plannotator-tui starts as the pane
+# process, renders a document from a review folder outside the checkout, quits on `q` with
+# status zero, and leaves nothing running. The fixture marker is generated per run, so a
+# stale buffer cannot satisfy the render assertion.
+#
+# The binary under test is fetched by the variant's own build command from the real release,
+# with both override variables absent, and its location and stamp are asserted before use.
+#
+# Run with no arguments for the default case. -Checkout and -Review let the path-matrix suite
+# reuse this lifecycle against awkward roots without restating it; caller-supplied directories
+# are left in place on exit, since the caller owns them. -HerdrExecutable skips the download
+# when a suite has already fetched and verified one.
+param(
+ [string]$Checkout,
+ [string]$Review,
+ [string]$HerdrExecutable,
+ [string]$LinkPath,
+ [string]$Label = "default"
+)
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+if (Test-Path variable:PSNativeCommandUseErrorActionPreference) {
+ $PSNativeCommandUseErrorActionPreference = $false
+}
+
+$repositoryRoot = Split-Path -Parent $PSScriptRoot
+$temporaryBase = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { $env:TEMP } else { $env:RUNNER_TEMP }
+$testRoot = Join-Path $temporaryBase ("herdr windows full pane " + [guid]::NewGuid())
+
+$herdrVersion = "0.9.0"
+$herdrSha256 = "b4508c445de1c1a68c760a01735da2aba2fa214b2aafd4b07f732e49b2a64b11"
+
+# Spaces in both roots: the review folder and the plugin root are separate path cases, and
+# the pane receives one as cwd while the program is resolved from the other.
+$callerOwnsDirectories = -not [string]::IsNullOrWhiteSpace($Checkout)
+$checkout = if ($callerOwnsDirectories) { $Checkout } else { Join-Path $testRoot "checkout with spaces" }
+$variantRoot = Join-Path $checkout "windows-full"
+$review = if (-not [string]::IsNullOrWhiteSpace($Review)) { $Review } else { Join-Path $testRoot "review folder" }
+$marker = "HERDRFULL-" + ([guid]::NewGuid().ToString("N").Substring(0, 12)).ToUpperInvariant()
+$fixture = "fixture $marker.md"
+
+$isolatedNames = @(
+ "XDG_CONFIG_HOME",
+ "XDG_STATE_HOME",
+ "HERDR_CONFIG_PATH",
+ "HERDR_SESSION",
+ "HERDR_SOCKET_PATH",
+ "HERDR_CLIENT_SOCKET_PATH",
+ "PLANNOTATOR_TUI_BIN",
+ "PLANNOTATOR_TUI_RELEASE_BASE",
+ "HERDR_ANNOTATE_BIN"
+)
+$oldEnvironment = @{}
+foreach ($name in $isolatedNames) {
+ $oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process")
+}
+
+# Distinct from the -HerdrExecutable parameter: PowerShell variable names are
+# case-insensitive, so reusing that spelling here would blank the parameter.
+$resolvedHerdr = $null
+
+function Assert-True {
+ param([bool]$Condition, [string]$Message)
+ if (-not $Condition) { throw $Message }
+}
+
+function Invoke-Herdr {
+ param([string[]]$Arguments)
+ $output = & $script:resolvedHerdr @Arguments *>&1 | Out-String
+ $output
+}
+
+function Get-TuiProcessPaths {
+ # Scoped to the staged executable so a developer's own plannotator-tui is never counted.
+ # Compared through GetFullPath and case-insensitively, because Windows reports a process
+ # image under a UNC root in its own spelling rather than the one used to launch it.
+ param([string]$Path)
+
+ # Windows reports the image of a process launched from a share in device form, where the
+ # \\server\share prefix appears as UNC\server\share. Folded back before comparing, or every
+ # UNC run looks like no process at all.
+ function Resolve-ImagePath {
+ param([string]$Candidate)
+ $value = if ($Candidate -like "UNC\*") { "\\" + $Candidate.Substring(4) } else { $Candidate }
+ try { [System.IO.Path]::GetFullPath($value) } catch { $value }
+ }
+
+ $wanted = Resolve-ImagePath -Candidate $Path
+ @(
+ Get-Process plannotator-tui -ErrorAction SilentlyContinue |
+ ForEach-Object {
+ $candidate = $null
+ try { $candidate = $_.Path } catch { $candidate = $null }
+ if ($null -ne $candidate) {
+ $full = Resolve-ImagePath -Candidate $candidate
+ if ($full -ieq $wanted) { $full }
+ }
+ }
+ )
+}
+
+try {
+ # .NET rather than New-Item throughout: these paths legitimately contain brackets, which
+ # PowerShell's -Path parameters treat as wildcards. Only -LiteralPath and the .NET APIs
+ # read them as the characters they are.
+ [System.IO.Directory]::CreateDirectory($review) | Out-Null
+ [System.IO.Directory]::CreateDirectory((Join-Path $variantRoot "scripts")) | Out-Null
+ [System.IO.Directory]::CreateDirectory((Join-Path $checkout "scripts")) | Out-Null
+ Set-Content -LiteralPath (Join-Path $review $fixture) -Encoding utf8 -Value @(
+ "# $marker",
+ "",
+ "Unique fixture content for this run: $marker"
+ )
+
+ # A staged checkout rather than the working tree: the build must work from a relocated
+ # copy, which is what an installed plugin is.
+ foreach ($name in @("plannotator-tui.version", "herdr-annotate.version")) {
+ [System.IO.File]::Copy((Join-Path $repositoryRoot $name), (Join-Path $checkout $name), $true)
+ }
+ foreach ($pair in @(
+ @("scripts\fetch-plannotator-tui.ps1", (Join-Path $checkout "scripts\fetch-plannotator-tui.ps1")),
+ @("scripts\fetch-herdr-annotate.ps1", (Join-Path $checkout "scripts\fetch-herdr-annotate.ps1")),
+ @("windows-full\herdr-plugin.toml", (Join-Path $variantRoot "herdr-plugin.toml")),
+ @("windows-full\scripts\fetch-plannotator-tui.ps1", (Join-Path $variantRoot "scripts\fetch-plannotator-tui.ps1"))
+ )) {
+ [System.IO.File]::Copy((Join-Path $repositoryRoot $pair[0]), $pair[1], $true)
+ }
+
+ # Both build entries from the manifest, run the way Herdr runs them: from the plugin root,
+ # with no override variables set, against the real release. The overrides are cleared here
+ # rather than with the rest of the isolation below, because the production fetch is the one
+ # thing that must not see them, and it happens first. $env: assignment rather than
+ # SetEnvironmentVariable: passing $null to the latter binds as an empty string and leaves
+ # the name defined, which a fetcher testing presence rather than emptiness then reads as a
+ # release base. The $env: form removes the name outright.
+ $env:PLANNOTATOR_TUI_BIN = $null
+ $env:PLANNOTATOR_TUI_RELEASE_BASE = $null
+ $env:HERDR_ANNOTATE_BIN = $null
+
+ Push-Location -LiteralPath $variantRoot
+ try {
+ # Kept, not discarded: both fetchers warn and exit zero by design so Lite survives a
+ # failed download, which means a silent staging failure is the normal shape of trouble
+ # here and the text is the only evidence of why.
+ $buildLog = @()
+ $buildLog += (& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass `
+ -File "..\scripts\fetch-herdr-annotate.ps1" *>&1 | Out-String)
+ $buildLog += (& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass `
+ -File "scripts\fetch-plannotator-tui.ps1" *>&1 | Out-String)
+ $script:buildOutput = ($buildLog -join "").Trim()
+ } finally {
+ Pop-Location
+ }
+
+ $tui = Join-Path $variantRoot "bin\plannotator-tui.exe"
+ $native = Join-Path $checkout "bin\herdr-annotate.exe"
+ Assert-True (Test-Path -LiteralPath $tui -PathType Leaf) `
+ "the variant build staged no plannotator-tui.exe`n--- build output ---`n$script:buildOutput"
+ Assert-True (Test-Path -LiteralPath $native -PathType Leaf) `
+ "the native build staged no herdr-annotate.exe`n--- build output ---`n$script:buildOutput"
+ $pin = (Get-Content -LiteralPath (Join-Path $checkout "plannotator-tui.version") -Raw).Trim()
+ $stamp = (Get-Content -LiteralPath (Join-Path $variantRoot "bin\plannotator-tui.version") -Raw).Trim()
+ Assert-True ($stamp -ceq $pin) "staged stamp $stamp does not match the pin $pin"
+ # Process creation takes the image path through the ANSI/MAX_PATH route and accepts no
+ # extended-length spelling, so past 260 characters a staged, verified, present binary still
+ # cannot be started. Reported as that, rather than as a stack trace about --version.
+ $reported = try {
+ (& $tui --version *>&1 | Out-String).Trim()
+ } catch {
+ throw (
+ "the staged TUI cannot be started: its path is $($tui.Length) characters" +
+ $(if ($tui.Length -gt 260) { ", past the $([int]260)-character process-creation limit" } else { "" }) +
+ ". Windows reported: $($_.Exception.Message.Split([char]10)[0].Trim())"
+ )
+ }
+ Assert-True ($reported -match [regex]::Escape($pin)) "staged TUI reports '$reported', expected $pin"
+ Write-Output "[$Label] staged plannotator-tui $pin at windows-full/bin, native runtime one level up"
+
+ if ([string]::IsNullOrWhiteSpace($HerdrExecutable)) {
+ $archive = Join-Path $testRoot "herdr.zip"
+ $expanded = Join-Path $testRoot "herdr"
+ Invoke-WebRequest -UseBasicParsing `
+ "https://github.com/herdrdev/herdr/releases/download/v$herdrVersion/herdr-windows-x86_64.zip" `
+ -OutFile $archive
+ $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant()
+ Assert-True ($hash -ceq $herdrSha256) "pinned Herdr $herdrVersion checksum differs: $hash"
+ Expand-Archive -LiteralPath $archive -DestinationPath $expanded
+ $found = Get-ChildItem -LiteralPath $expanded -Filter "herdr.exe" -File -Recurse | Select-Object -First 1
+ Assert-True ($null -ne $found) "pinned Herdr archive contains no herdr.exe"
+ $script:resolvedHerdr = $found.FullName
+ } else {
+ Assert-True (Test-Path -LiteralPath $HerdrExecutable -PathType Leaf) `
+ "no Herdr executable at $HerdrExecutable"
+ $script:resolvedHerdr = $HerdrExecutable
+ }
+
+ $env:XDG_CONFIG_HOME = Join-Path $testRoot "config"
+ $env:XDG_STATE_HOME = Join-Path $testRoot "state"
+ $env:HERDR_CONFIG_PATH = $null
+ $env:HERDR_SESSION = "windowsfullpane"
+ $env:HERDR_SOCKET_PATH = Join-Path $testRoot "server.sock"
+ $env:HERDR_CLIENT_SOCKET_PATH = Join-Path $testRoot "client.sock"
+ $env:PLANNOTATOR_TUI_BIN = $null
+ $env:PLANNOTATOR_TUI_RELEASE_BASE = $null
+
+ Start-Process -FilePath $script:resolvedHerdr -ArgumentList "server" -WindowStyle Hidden
+ $deadline = (Get-Date).AddSeconds(30)
+ do {
+ Start-Sleep -Milliseconds 500
+ $status = Invoke-Herdr -Arguments @("status")
+ } while ($status -notmatch "status: running" -and (Get-Date) -lt $deadline)
+ Assert-True ($status -match "status: running") "the isolated Herdr server did not start"
+
+ $created = Invoke-Herdr -Arguments @("workspace", "create", "--cwd", $review) | ConvertFrom-Json
+ Assert-True ($created.result.type -ceq "workspace_created") "no isolated workspace was created"
+ $originPane = $created.result.root_pane.pane_id
+
+ # -LinkPath lets a caller hand Herdr a different spelling of the same directory -- an
+ # extended-length \\?\ root, say -- while staging still happens through the ordinary path,
+ # which is what Herdr's own build step does. Herdr must resolve it to the same directory.
+ $linkTarget = if ([string]::IsNullOrWhiteSpace($LinkPath)) { $variantRoot } else { $LinkPath }
+ $linked = Invoke-Herdr -Arguments @("plugin", "link", $linkTarget, "--enabled") | ConvertFrom-Json
+ Assert-True ($linked.result.type -ceq "plugin_linked") "the isolated Herdr did not link Windows Full"
+ Assert-True ($linked.result.plugin.plugin_id -ceq "annotate") "the linked plugin id is not annotate"
+ $reportedRoot = [string]$linked.result.plugin.plugin_root
+ Assert-True (
+ [System.IO.Path]::GetFullPath($reportedRoot.Replace("\\?\", "")) -ceq
+ [System.IO.Path]::GetFullPath($variantRoot.Replace("\\?\", ""))
+ ) "Herdr reported plugin root '$reportedRoot', which is not the staged directory"
+
+ # @() at every call site: an empty array returned from a function unrolls to $null.
+ $before = @(Get-TuiProcessPaths -Path $tui)
+ Assert-True ($before.Count -eq 0) "a staged plannotator-tui was already running before the pane opened"
+
+ $opened = Invoke-Herdr -Arguments @(
+ "plugin", "pane", "open", "--plugin", "annotate", "--entrypoint", "doc", "--cwd", $review
+ ) | ConvertFrom-Json
+ Assert-True ($opened.result.type -ceq "plugin_pane_opened") "the doc pane did not open"
+ $docPane = $opened.result.plugin_pane.pane.pane_id
+ Assert-True ($docPane -cne $originPane) "the review pane and the originating pane share an id"
+
+ # The render assertion: a per-run marker, read back out of the pane's own terminal.
+ # --match, not --pattern, and --timeout is milliseconds. Getting either wrong makes the call
+ # fail instead of waiting, and the assertion below then races the TUI's first paint -- which
+ # is why its exit status is checked rather than discarded.
+ $waited = Invoke-Herdr -Arguments @(
+ "pane", "wait-output", $docPane, "--match", $marker, "--timeout", "30000"
+ )
+ Assert-True ($LASTEXITCODE -eq 0) "waiting for $marker in the review pane failed: $waited"
+ $rendered = Invoke-Herdr -Arguments @("pane", "read", $docPane, "--source", "visible", "--format", "text")
+ Assert-True ($rendered -match [regex]::Escape($marker)) `
+ "the review pane never rendered $marker`n$rendered"
+ Assert-True ($rendered -match "annotations") "the review pane rendered no plannotator-tui status line"
+ Write-Output "[$Label] doc pane rendered $marker; plugin root $($variantRoot.Length) chars, review $($review.Length)"
+
+ $running = @(Get-TuiProcessPaths -Path $tui)
+ $seen = @(Get-Process plannotator-tui -ErrorAction SilentlyContinue | ForEach-Object {
+ try { $_.Path } catch { "" }
+ })
+ Assert-True ($running.Count -eq 1) (
+ "expected exactly one staged plannotator-tui at`n $tui`nfound $($running.Count). " +
+ "Running images: $($seen -join '; ')"
+ )
+
+ Invoke-Herdr -Arguments @("pane", "send-keys", $docPane, "q") | Out-Null
+ $deadline = (Get-Date).AddSeconds(20)
+ do {
+ Start-Sleep -Milliseconds 500
+ $panes = Invoke-Herdr -Arguments @("pane", "list")
+ } while ($panes -match [regex]::Escape($docPane) -and (Get-Date) -lt $deadline)
+ Assert-True ($panes -notmatch [regex]::Escape($docPane)) "the review pane stayed open after q"
+ Assert-True ($panes -match [regex]::Escape($originPane)) "the originating pane did not survive the review"
+
+ $after = @(Get-TuiProcessPaths -Path $tui)
+ Assert-True ($after.Count -eq 0) "a staged plannotator-tui survived the review: $($after -join ',')"
+
+ $log = Join-Path $env:XDG_CONFIG_HOME "herdr\sessions\$($env:HERDR_SESSION)\herdr-server.log"
+ Assert-True (Test-Path -LiteralPath $log -PathType Leaf) "no isolated server log at $log"
+ $exits = @(
+ Get-Content -LiteralPath $log |
+ Where-Object { $_ -match 'event="pane\.exit"' } |
+ Select-Object -Last 1
+ )
+ Assert-True ($exits.Count -eq 1) "the isolated server logged no pane exit"
+ Assert-True ($exits[0] -match "code: 0\b") "the review pane exited abnormally: $($exits[0])"
+ Write-Output "[$Label] q closed the review pane with status zero and left no process behind"
+} finally {
+ if ($null -ne $script:resolvedHerdr) {
+ & $script:resolvedHerdr server stop *>&1 | Out-Null
+ Start-Sleep -Seconds 2
+ }
+ foreach ($name in $isolatedNames) {
+ if ($null -eq $oldEnvironment[$name]) {
+ [Environment]::SetEnvironmentVariable($name, $null, "Process")
+ } else {
+ [Environment]::SetEnvironmentVariable($name, $oldEnvironment[$name], "Process")
+ }
+ }
+ Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue
+}
diff --git a/scripts/test-herdr-windows-full-paths.ps1 b/scripts/test-herdr-windows-full-paths.ps1
new file mode 100644
index 0000000..0643a62
--- /dev/null
+++ b/scripts/test-herdr-windows-full-paths.ps1
@@ -0,0 +1,137 @@
+# §3's path matrix: the same review-pane lifecycle driven from awkward plugin roots and
+# review folders. The lifecycle itself is not restated here -- each case shells out to
+# test-herdr-windows-full-pane.ps1, so what is proved per case is exactly what is proved in
+# the default case: real fetch, render of a per-run marker, `q`, clean teardown.
+#
+# Local-drive cases are required: a failure fails the run rather than downgrading to a note.
+# UNC is attempted against a loopback share where one is reachable and reported UNVERIFIED
+# with its limitation otherwise, since a loopback share is not a remote file server.
+#
+# Measured lengths and the machine's long-path setting are printed, because every result here
+# depends on them and the acceptance record asks for the actual numbers.
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+if (Test-Path variable:PSNativeCommandUseErrorActionPreference) {
+ $PSNativeCommandUseErrorActionPreference = $false
+}
+
+$paneScript = Join-Path $PSScriptRoot "test-herdr-windows-full-pane.ps1"
+$temporaryBase = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { $env:TEMP } else { $env:RUNNER_TEMP }
+$suiteRoot = Join-Path $temporaryBase ("herdr full paths " + [guid]::NewGuid())
+
+$herdrVersion = "0.9.0"
+$herdrSha256 = "b4508c445de1c1a68c760a01735da2aba2fa214b2aafd4b07f732e49b2a64b11"
+
+$longPaths = (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
+ -Name LongPathsEnabled -ErrorAction SilentlyContinue).LongPathsEnabled
+Write-Output "windows $([Environment]::OSVersion.Version) $env:PROCESSOR_ARCHITECTURE, LongPathsEnabled=$longPaths"
+
+function Assert-True {
+ param([bool]$Condition, [string]$Message)
+ if (-not $Condition) { throw $Message }
+}
+
+$results = [ordered]@{}
+# Both are read by the finally below, and under Set-StrictMode reading an unset variable
+# is itself an error -- a failure before the UNC probe would report that instead of its
+# own cause.
+$uncUsable = $false
+$uncRoot = $null
+
+try {
+ New-Item -ItemType Directory -Force $suiteRoot | Out-Null
+
+ $archive = Join-Path $suiteRoot "herdr.zip"
+ Invoke-WebRequest -UseBasicParsing `
+ "https://github.com/herdrdev/herdr/releases/download/v$herdrVersion/herdr-windows-x86_64.zip" `
+ -OutFile $archive
+ $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant()
+ Assert-True ($hash -ceq $herdrSha256) "pinned Herdr $herdrVersion checksum differs: $hash"
+ Expand-Archive -LiteralPath $archive -DestinationPath (Join-Path $suiteRoot "herdr")
+ $herdr = Get-ChildItem -LiteralPath (Join-Path $suiteRoot "herdr") -Filter "herdr.exe" -File -Recurse |
+ Select-Object -First 1
+ Assert-True ($null -ne $herdr) "pinned Herdr archive contains no herdr.exe"
+
+ # Every character class §3 names, in one segment: spaces, Unicode, an apostrophe, an
+ # ampersand, parentheses, a dollar, a percent and brackets.
+ $awkward = "pä th 'x' & (y) `$z %w [v]"
+
+ # Long enough that the plugin root plus \bin\plannotator-tui.exe passes the legacy limit.
+ $filler = ("deep-" + ("n" * 40))
+ $longRoot = Join-Path $suiteRoot (($filler, $filler, $filler) -join "\")
+
+ $cases = @(
+ @{ Label = "special-characters"; Checkout = (Join-Path $suiteRoot $awkward); Review = (Join-Path $suiteRoot "$awkward review"); Required = $true }
+ # Not required, and deliberately so. The manifest's build command passes a relative
+ # -File argument, and Herdr resolves only the program against the plugin root, not the
+ # arguments. Windows PowerShell then resolves that argument against the pane cwd under
+ # MAX_PATH, so a long plugin root cannot find a script that plainly exists. The checklist
+ # forbids fixing this by changing the root manifest, so it is measured and reported.
+ @{ Label = "long-path"; Checkout = $longRoot; Review = (Join-Path $suiteRoot "long review"); Required = $false }
+ )
+
+ # An extended-length spelling of an ordinary drive path. Staging still goes through the
+ # ordinary path, because that is what Herdr's build step does -- Herdr normalises the root
+ # it stores. Only the spelling handed to `plugin link` is extended, and Herdr must resolve
+ # it to the same directory and run the same lifecycle from it.
+ $extendedCheckout = Join-Path $suiteRoot "extended checkout"
+ $cases += @{
+ Label = "extended-length-root"
+ Checkout = $extendedCheckout
+ Review = (Join-Path $suiteRoot "extended review")
+ LinkPath = ("\\?\" + (Join-Path $extendedCheckout "windows-full"))
+ Required = $true
+ }
+
+ $uncBase = "\\localhost\Users\" + $env:USERNAME
+ $uncUsable = $false
+ try {
+ $uncProbe = Join-Path $uncBase ("herdr-unc-probe-" + [guid]::NewGuid().ToString("N") + ".tmp")
+ Set-Content -LiteralPath $uncProbe -Value "probe" -ErrorAction Stop
+ Remove-Item -LiteralPath $uncProbe -Force -ErrorAction SilentlyContinue
+ $uncUsable = $true
+ } catch {
+ $uncUsable = $false
+ }
+ if ($uncUsable) {
+ $uncRoot = Join-Path $uncBase ("herdr-full-unc-" + [guid]::NewGuid().ToString("N").Substring(0, 8))
+ $cases += @{ Label = "unc-loopback"; Checkout = (Join-Path $uncRoot "checkout"); Review = (Join-Path $uncRoot "review"); Required = $false }
+ } else {
+ $results["unc-loopback"] = "UNVERIFIED: no writable loopback share on this host"
+ }
+
+ foreach ($case in $cases) {
+ $variantRoot = Join-Path $case.Checkout "windows-full"
+ $binary = Join-Path $variantRoot "bin\plannotator-tui.exe"
+ Write-Output ""
+ Write-Output "--- $($case.Label): plugin root $($variantRoot.Length) chars, binary $($binary.Length) chars"
+ try {
+ $linkPath = if ($case.Contains("LinkPath")) { [string]$case.LinkPath } else { "" }
+ & $paneScript -Checkout $case.Checkout -Review $case.Review `
+ -HerdrExecutable $herdr.FullName -LinkPath $linkPath -Label $case.Label
+ $results[$case.Label] = "PASS (root $($variantRoot.Length), binary $($binary.Length))"
+ } catch {
+ if ($case.Required) { throw "$($case.Label) failed: $($_.Exception.Message)" }
+ $results[$case.Label] = "LIMITATION: $($_.Exception.Message)"
+ }
+ }
+
+ Write-Output ""
+ Write-Output "path matrix:"
+ foreach ($key in $results.Keys) { Write-Output (" {0,-20} {1}" -f $key, $results[$key]) }
+ $unmet = @($results.Values | Where-Object { $_ -notmatch "^PASS" })
+ if ($unmet.Count -gt 0) {
+ Write-Output ""
+ Write-Output "NOT CLAIMED AS PASSING, reported for review rather than skipped:"
+ foreach ($key in $results.Keys) {
+ if ($results[$key] -notmatch "^PASS") { Write-Output (" {0}: {1}" -f $key, $results[$key]) }
+ }
+ }
+ Assert-True (@($results.Values | Where-Object { $_ -notmatch "^(PASS|UNVERIFIED|LIMITATION)" }).Count -eq 0) `
+ "a path case reported no classification"
+} finally {
+ Remove-Item -LiteralPath $suiteRoot -Recurse -Force -ErrorAction SilentlyContinue
+ if ($uncUsable -and $null -ne $uncRoot) {
+ Remove-Item -LiteralPath $uncRoot -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
diff --git a/scripts/test-herdr-windows-full-plugin.ps1 b/scripts/test-herdr-windows-full-plugin.ps1
new file mode 100644
index 0000000..fb76af1
--- /dev/null
+++ b/scripts/test-herdr-windows-full-plugin.ps1
@@ -0,0 +1,169 @@
+# Windows Full against pinned Herdr releases, in an isolated config/state/socket so the
+# machine's own Herdr install and session are never touched.
+#
+# Two things are proved here that the manifest test cannot see, because they are Herdr's
+# behaviour rather than the file's contents: 0.8.2 refuses the variant for its minimum, and
+# 0.9.0 accepts it and reports every action, pane and link handler as effective on Windows.
+# The plugin root contains spaces on purpose.
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+if (Test-Path variable:PSNativeCommandUseErrorActionPreference) {
+ $PSNativeCommandUseErrorActionPreference = $false
+}
+
+$repositoryRoot = Split-Path -Parent $PSScriptRoot
+# RUNNER_TEMP is the CI location; falling back to TEMP lets a human run this unchanged when
+# recording the native qualification matrix.
+$temporaryBase = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { $env:TEMP } else { $env:RUNNER_TEMP }
+$testRoot = Join-Path $temporaryBase ("herdr windows full " + [guid]::NewGuid())
+$pluginRoot = Join-Path $testRoot "plugin root with spaces"
+
+$releases = @(
+ @{
+ Version = "0.8.2"
+ Sha256 = "0ab3d0fe1434d55757997542b978c771d642987bb15a7130f4160f0db38821d5"
+ Accepts = $false
+ }
+ @{
+ Version = "0.9.0"
+ Sha256 = "b4508c445de1c1a68c760a01735da2aba2fa214b2aafd4b07f732e49b2a64b11"
+ Accepts = $true
+ }
+)
+
+$isolatedNames = @(
+ "XDG_CONFIG_HOME",
+ "XDG_STATE_HOME",
+ "HERDR_CONFIG_PATH",
+ "HERDR_SESSION",
+ "HERDR_SOCKET_PATH",
+ "HERDR_CLIENT_SOCKET_PATH"
+)
+$oldEnvironment = @{}
+foreach ($name in $isolatedNames) {
+ $oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process")
+}
+
+function Assert-True {
+ param([bool]$Condition, [string]$Message)
+ if (-not $Condition) { throw $Message }
+}
+
+function Get-Platforms {
+ # An entry with no gate carries no `platforms` key at all, and Herdr omits it from the
+ # JSON rather than echoing the manifest default. Under StrictMode that is a missing
+ # property, not an empty one, so it is read through PSObject and reported as "no gate".
+ param($Item)
+ $property = $Item.PSObject.Properties['platforms']
+ if ($null -eq $property -or $null -eq $property.Value) { return @() }
+ @($property.Value)
+}
+
+function Get-Herdr {
+ param([string]$Version, [string]$Sha256, [string]$Destination)
+ $archive = Join-Path $Destination "herdr-$Version.zip"
+ $expanded = Join-Path $Destination "herdr-$Version"
+ Invoke-WebRequest -UseBasicParsing `
+ "https://github.com/herdrdev/herdr/releases/download/v$Version/herdr-windows-x86_64.zip" `
+ -OutFile $archive
+ $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant()
+ Assert-True ($hash -ceq $Sha256) "pinned Herdr $Version archive checksum differs: $hash"
+ Expand-Archive -LiteralPath $archive -DestinationPath $expanded
+ $executable = Get-ChildItem -LiteralPath $expanded -Filter "herdr.exe" -File -Recurse |
+ Select-Object -First 1
+ Assert-True ($null -ne $executable) "pinned Herdr $Version archive contains no herdr.exe"
+ $executable.FullName
+}
+
+try {
+ New-Item -ItemType Directory -Force $pluginRoot | Out-Null
+ Copy-Item -LiteralPath (Join-Path $repositoryRoot "windows-full\herdr-plugin.toml") `
+ -Destination $pluginRoot
+
+ foreach ($release in $releases) {
+ $version = $release.Version
+ $herdr = Get-Herdr -Version $version -Sha256 $release.Sha256 -Destination $testRoot
+
+ # A separate config, state and socket per release: the point is that nothing here can
+ # reach the machine's own server, and that the two releases cannot reach each other's.
+ $env:XDG_CONFIG_HOME = Join-Path $testRoot "config $version"
+ $env:XDG_STATE_HOME = Join-Path $testRoot "state $version"
+ $env:HERDR_CONFIG_PATH = $null
+ $env:HERDR_SESSION = $null
+ $env:HERDR_SOCKET_PATH = Join-Path $testRoot "server-$version.sock"
+ $env:HERDR_CLIENT_SOCKET_PATH = Join-Path $testRoot "client-$version.sock"
+
+ $output = & $herdr plugin link $pluginRoot --enabled *>&1 | Out-String
+
+ if (-not $release.Accepts) {
+ # The acceptance checklist names the code `plugin_requires_newer_herdr`. What 0.8.2's
+ # `plugin link` actually prints is an unstructured message, so both spellings are
+ # accepted and the observed one is echoed rather than hidden behind a pass.
+ Assert-True (
+ $output -match "plugin_requires_newer_herdr" -or
+ $output -match "requires Herdr 0\.9\.0 or newer"
+ ) "Herdr $version did not reject Windows Full for its minimum: $output"
+ Assert-True ($output -notmatch "plugin_linked") "Herdr $version linked Windows Full anyway"
+ Write-Output "herdr $version rejects windows-full: $($output.Trim())"
+ continue
+ }
+
+ $linked = $output | ConvertFrom-Json
+ Assert-True ($linked.result.type -ceq "plugin_linked") `
+ "Herdr $version did not link Windows Full: $output"
+
+ $listedText = & $herdr plugin list --plugin annotate --json *>&1 | Out-String
+ $listed = $listedText | ConvertFrom-Json
+ $plugins = @($listed.result.plugins)
+ Assert-True ($plugins.Count -eq 1) "Herdr $version did not list exactly one Annotate plugin"
+ $plugin = $plugins[0]
+
+ # Every entry must be effective on Windows. An inherited Unix gate would leave the
+ # variant installed and the review half silently unreachable, which is the state this
+ # whole variant exists to end.
+ $actionIds = @($plugin.actions | ForEach-Object { $_.id })
+ foreach ($id in @("capture", "copy-context", "copy-archive", "manage", "open", "open-link", "last")) {
+ Assert-True ($actionIds -contains $id) "Herdr $version omitted action $id"
+ $action = @($plugin.actions | Where-Object { $_.id -ceq $id })
+ # @() at the call site: an empty array returned from a function unrolls to $null.
+ $platforms = @(Get-Platforms -Item $action[0])
+ Assert-True ($platforms.Count -eq 0 -or $platforms -contains "windows") `
+ "action $id is not effective on Windows: $($platforms -join ',')"
+ }
+ Assert-True ($actionIds.Count -eq 7) "Herdr $version listed $($actionIds.Count) actions, expected 7"
+
+ $paneIds = @($plugin.panes | ForEach-Object { $_.id })
+ foreach ($id in @("editor", "manager", "doc")) {
+ Assert-True ($paneIds -contains $id) "Herdr $version omitted pane $id"
+ }
+ Assert-True ($paneIds.Count -eq 3) "Herdr $version listed $($paneIds.Count) panes, expected 3"
+
+ $doc = @($plugin.panes | Where-Object { $_.id -ceq "doc" })[0]
+ $docPlatforms = @(Get-Platforms -Item $doc)
+ Assert-True ($docPlatforms.Count -eq 0 -or $docPlatforms -contains "windows") `
+ "the doc pane is not effective on Windows"
+ # No launcher may sit between Herdr and the TUI: a process started through the
+ # extended-length path Herdr resolves to exits immediately unless it is native.
+ $docCommand = @($doc.command)
+ Assert-True (
+ $docCommand.Count -eq 3 -and
+ $docCommand[0] -ceq "./bin/plannotator-tui.exe" -and
+ $docCommand[1] -ceq "herdr" -and
+ $docCommand[2] -ceq "pane"
+ ) "the doc pane is not direct argv: $($docCommand -join ' ')"
+
+ $handler = @($plugin.link_handlers | Where-Object { $_.id -ceq "markdown-file" })
+ Assert-True ($handler.Count -eq 1) "Herdr $version omitted the markdown-file link handler"
+
+ Write-Output "herdr $version accepts windows-full: 7 actions, 3 panes, direct-argv doc pane"
+ }
+} finally {
+ foreach ($name in $isolatedNames) {
+ if ($null -eq $oldEnvironment[$name]) {
+ [Environment]::SetEnvironmentVariable($name, $null, "Process")
+ } else {
+ [Environment]::SetEnvironmentVariable($name, $oldEnvironment[$name], "Process")
+ }
+ }
+ Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue
+}
diff --git a/scripts/test-windows-full-manifest.py b/scripts/test-windows-full-manifest.py
index 75a2daf..aadbc62 100644
--- a/scripts/test-windows-full-manifest.py
+++ b/scripts/test-windows-full-manifest.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Check the gated distributed Full manifest and development parity."""
+"""Check the gated distributed Full manifest, Windows Full, and development parity."""
from __future__ import annotations
@@ -10,6 +10,34 @@
PROGRAM = "./bin/plannotator-tui.exe"
NATIVE_PROGRAM = "./bin/herdr-annotate.exe"
+# Windows Full sits one directory down, so it shares the repository's single native runtime
+# the way lite/ does and keeps only plannotator-tui in its own bin/.
+WINDOWS_NATIVE_PROGRAM = "../bin/herdr-annotate.exe"
+WINDOWS_FULL_BUILDS = [
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ "../scripts/fetch-herdr-annotate.ps1",
+ ],
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ "scripts/fetch-plannotator-tui.ps1",
+ ],
+]
+WINDOWS_FULL_PANE = [PROGRAM, "herdr", "pane"]
+PANE_SHAPE = {
+ "editor": ("Annotate", "popup", 88, 24),
+ "manager": ("Annotations", "popup", 100, 30),
+}
FULL_PLATFORMS = {"macos", "linux"}
UNIX_BUILD = ["bash", "scripts/fetch-plannotator-tui.sh"]
NATIVE_UNIX_BUILD = ["bash", "scripts/fetch-herdr-annotate.sh"]
@@ -202,6 +230,104 @@ def check_development(path: Path) -> None:
fail(path, f"development markdown-file points to {handler.get('action')!r}")
+def surface(path: Path, manifest: dict[str, object], table: str) -> set[tuple[object, ...]]:
+ """Ids, titles, descriptions and contexts, so parity is compared rather than assumed."""
+ entries = manifest.get(table, [])
+ if not isinstance(entries, list):
+ fail(path, f"[[{table}]] is not an array")
+ return {
+ (
+ item.get("id"),
+ item.get("title"),
+ item.get("description"),
+ tuple(item.get("contexts", [])),
+ )
+ for item in entries
+ if isinstance(item, dict)
+ }
+
+
+def check_windows_full(path: Path, root_path: Path) -> None:
+ manifest = load(path)
+ root = load(root_path)
+
+ if manifest.get("id") != root.get("id") or manifest.get("name") != root.get("name"):
+ fail(path, "id and name must match the root manifest")
+ if manifest.get("version") != root.get("version"):
+ fail(path, f"version {manifest.get('version')!r} differs from the root manifest")
+ if platforms(path, manifest, {}) != {"windows"}:
+ fail(path, f"top-level platforms are {platforms(path, manifest, {})!r}")
+ if manifest.get("min_herdr_version") != "0.9.0":
+ fail(path, "Windows Full requires Herdr 0.9.0, where panes resolve relative programs")
+
+ build_entries = builds(path, manifest)
+ if [item.get("command") for item in build_entries] != WINDOWS_FULL_BUILDS:
+ fail(path, f"unexpected builds: {[i.get('command') for i in build_entries]!r}")
+ for item in build_entries:
+ # A build gated to macOS/Linux would stage nothing here, leaving the manifest
+ # pointing at binaries that were never fetched.
+ if "platforms" in item:
+ fail(path, f"a build carries a platform gate: {item['platforms']!r}")
+ # A pinned command string still has to name a file that exists. The target is
+ # resolved from the manifest's own directory, which is the plugin root Herdr runs
+ # the build from, rather than from whatever directory this check was started in.
+ command = item.get("command", [])
+ target = command[command.index("-File") + 1]
+ script = (path.parent / target).resolve()
+ if not script.is_file():
+ fail(path, f"build script {target!r} does not exist at {script}")
+
+ # Parity is the point of the variant: the same surface, reached a different way.
+ if surface(path, manifest, "actions") != surface(root_path, root, "actions"):
+ fail(path, "action ids, titles, descriptions or contexts differ from the root manifest")
+
+ for table in ("actions", "panes", "link_handlers"):
+ for item in manifest.get(table, []):
+ identifier = item.get("id")
+ # An inherited Unix gate would silently disable the entry on the only platform
+ # this variant targets, which is the bug the variant exists to avoid.
+ if "platforms" in item:
+ fail(path, f"{table}.{identifier} carries a platform gate: {item['platforms']!r}")
+ command = item.get("command", [])
+ if any("sh" in argument.lower() or "$" in argument for argument in command):
+ fail(path, f"shell or interpolation in {table}.{identifier}: {command!r}")
+
+ for identifier in NATIVE_ENTRIES["actions"]:
+ command = entry(path, manifest, "actions", identifier).get("command")
+ if command != [WINDOWS_NATIVE_PROGRAM, identifier]:
+ fail(path, f"actions.{identifier} must reuse the shared runtime: {command!r}")
+ for identifier, expected in ACTION_COMMANDS.items():
+ command = entry(path, manifest, "actions", identifier).get("command")
+ if command != expected:
+ fail(path, f"unexpected actions.{identifier} argv: {command!r}")
+
+ panes = manifest.get("panes", [])
+ if len(panes) != 3:
+ fail(path, f"expected exactly three panes, found {len(panes)}")
+ for identifier, (title, placement, width, height) in PANE_SHAPE.items():
+ pane = entry(path, manifest, "panes", identifier)
+ if (pane.get("title"), pane.get("placement")) != (title, placement):
+ fail(path, f"panes.{identifier} shape differs from the root manifest")
+ if (pane.get("width"), pane.get("height")) != (width, height):
+ fail(path, f"panes.{identifier} is not {width}x{height}")
+ if pane.get("command") != [WINDOWS_NATIVE_PROGRAM, identifier]:
+ fail(path, f"panes.{identifier} must reuse the shared runtime")
+
+ doc = entry(path, manifest, "panes", "doc")
+ if (doc.get("title"), doc.get("placement")) != ("Annotate", "overlay"):
+ fail(path, "panes.doc must stay an overlay titled Annotate")
+ # No launcher may sit between Herdr and the TUI: a process started through an
+ # extended-length path exits immediately unless it is the native binary itself.
+ if doc.get("command") != WINDOWS_FULL_PANE:
+ fail(path, f"panes.doc must be direct argv, found {doc.get('command')!r}")
+
+ handler = entry(path, manifest, "link_handlers", "markdown-file")
+ root_handler = entry(root_path, root, "link_handlers", "markdown-file")
+ for key in ("title", "pattern", "action"):
+ if handler.get(key) != root_handler.get(key):
+ fail(path, f"markdown-file {key} differs from the root manifest")
+
+
def main() -> None:
if len(sys.argv) > 2:
raise SystemExit("usage: test-windows-full-manifest.py [development-manifest]")
@@ -211,6 +337,10 @@ def main() -> None:
root / "plannotator-tui.version",
root / "herdr-annotate.version",
)
+ check_windows_full(
+ root / "windows-full" / "herdr-plugin.toml",
+ root / "herdr-plugin.toml",
+ )
if len(sys.argv) == 2:
check_development(Path(sys.argv[1]))
diff --git a/windows-full/bin/.gitkeep b/windows-full/bin/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/windows-full/herdr-plugin.toml b/windows-full/herdr-plugin.toml
new file mode 100644
index 0000000..0bf99b4
--- /dev/null
+++ b/windows-full/herdr-plugin.toml
@@ -0,0 +1,120 @@
+# Herdr Annotate, Windows Full: the terminal annotation tools plus document review.
+# Install with: herdr plugin install plannotator/herdr-annotate/windows-full
+# The repository root keeps the macOS/Linux Full plugin; lite/ keeps the terminal-only
+# variant, which is what Windows on Herdr 0.8.x should install.
+#
+# Herdr 0.9.0 is required because this variant starts plannotator-tui as the pane process
+# itself. Herdr resolves an explicit relative program against the plugin root and passes the
+# review folder as the pane cwd, so "./bin/plannotator-tui.exe" reaches the staged binary
+# from a cwd that contains no bin/. Measured on 0.9.0 (Windows 11, x86_64): a missing
+# program reports CreateProcessW `"\\?\\bin\.exe"` in cwd `Some()`, which is the resolution this manifest depends on.
+#
+# No launcher may sit between Herdr and the TUI. The resolved program reaches CreateProcessW
+# in extended-length form, and while a native binary runs from a \\?\ path unchanged, a
+# powershell.exe image started that way exits 0xFFFF0000 immediately. A wrapper would not be
+# slower or less tidy here; it would not stay up.
+#
+# The native annotation runtime is shared with the repository root, reached one directory up
+# the way lite/ reaches it. Only plannotator-tui is staged inside this variant, because its
+# fetch is Windows-specific and the root must keep staging its own copy for Unix.
+
+id = "annotate"
+name = "Annotate"
+version = "0.4.0"
+min_herdr_version = "0.9.0"
+description = "Comment on terminal selections and copy annotations as agent context."
+platforms = ["windows"]
+
+[[build]]
+command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "../scripts/fetch-herdr-annotate.ps1"]
+
+# Stages plannotator-tui into windows-full/bin/. The wrapper resolves both the shared fetcher
+# and the destination from its own location, so the build depends on neither the caller's cwd
+# nor HERDR_PLUGIN_ROOT, and the shared fetcher keeps its default destination for every other
+# caller.
+[[build]]
+command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "scripts/fetch-plannotator-tui.ps1"]
+
+[[actions]]
+id = "capture"
+title = "Annotate selection"
+description = "Open a comment dialog for the current terminal selection."
+contexts = ["pane"]
+command = ["../bin/herdr-annotate.exe", "capture"]
+
+[[actions]]
+id = "copy-context"
+title = "Copy annotations as context"
+description = "Copy all saved annotations to the clipboard as Markdown."
+contexts = ["global"]
+command = ["../bin/herdr-annotate.exe", "copy-context"]
+
+[[actions]]
+id = "copy-archive"
+title = "Copy annotations as context and archive them"
+description = "Copy all saved annotations to the clipboard as Markdown, then archive them."
+contexts = ["global"]
+command = ["../bin/herdr-annotate.exe", "copy-archive"]
+
+[[actions]]
+id = "manage"
+title = "Manage annotations"
+description = "Browse, copy, archive, restore, and delete annotations."
+contexts = ["global"]
+command = ["../bin/herdr-annotate.exe", "manage"]
+
+[[panes]]
+id = "editor"
+title = "Annotate"
+placement = "popup"
+width = 88
+height = 24
+command = ["../bin/herdr-annotate.exe", "editor"]
+
+[[panes]]
+id = "manager"
+title = "Annotations"
+placement = "popup"
+width = 100
+height = 30
+command = ["../bin/herdr-annotate.exe", "manager"]
+
+# Where it opens (overlay | split | popup) remains the user's choice in plannotator-tui's
+# config file.
+[[panes]]
+id = "doc"
+title = "Annotate"
+placement = "overlay"
+command = ["./bin/plannotator-tui.exe", "herdr", "pane"]
+
+# Both actions run the TUI directly, which reads Herdr's invocation context: the focused
+# pane's folder (open) or the clicked file:// link (open-link), and the focused pane's agent
+# as the target the review is sent back to.
+[[actions]]
+id = "open"
+title = "Annotate: open here"
+description = "Review the focused pane's folder in plannotator-tui and send feedback to its agent."
+contexts = ["workspace", "pane"]
+command = ["./bin/plannotator-tui.exe", "herdr", "open"]
+
+[[actions]]
+id = "open-link"
+title = "Annotate this file"
+description = "Open a Ctrl-clicked Markdown file in plannotator-tui."
+contexts = ["pane"]
+command = ["./bin/plannotator-tui.exe", "herdr", "open"]
+
+[[actions]]
+id = "last"
+title = "Annotate: agent's last message"
+description = "Review the focused agent's most recent message in plannotator-tui and send feedback back."
+contexts = ["pane"]
+command = ["./bin/plannotator-tui.exe", "herdr", "last"]
+
+# Ctrl-click on a file:// Markdown link. Anchored on the scheme so web links never match.
+[[link_handlers]]
+id = "markdown-file"
+title = "Annotate this file"
+pattern = "^file://.*\\.(md|markdown|mdx)$"
+action = "open-link"
diff --git a/windows-full/scripts/fetch-plannotator-tui.ps1 b/windows-full/scripts/fetch-plannotator-tui.ps1
new file mode 100644
index 0000000..2f9149c
--- /dev/null
+++ b/windows-full/scripts/fetch-plannotator-tui.ps1
@@ -0,0 +1,22 @@
+# Stages plannotator-tui into windows-full/bin/ by handing the shared fetcher an explicit
+# destination. Both paths are derived from this script's own location, so the build does not
+# depend on the caller's working directory, on HERDR_PLUGIN_ROOT being exported, or on the
+# temporary checkout path surviving the install. The shared fetcher keeps the single release
+# pin at the repository root and its own default destination for every other caller.
+$ErrorActionPreference = "Stop"
+
+# .NET rather than Split-Path: an extended-length \\?\ root is a path matrix row, and
+# Split-Path cannot parse one -- it reports a null drive and returns nothing.
+$variantRoot = [System.IO.Path]::GetDirectoryName($PSScriptRoot)
+$repositoryRoot = [System.IO.Path]::GetDirectoryName($variantRoot)
+$shared = [System.IO.Path]::Combine($repositoryRoot, "scripts", "fetch-plannotator-tui.ps1")
+if (-not (Test-Path -LiteralPath $shared -PathType Leaf)) {
+ throw "shared fetcher not found at $shared"
+}
+
+& $shared -DestinationDirectory ([System.IO.Path]::Combine($variantRoot, "bin"))
+
+# The shared fetcher warns and exits zero when a download, checksum, architecture or
+# replacement step fails, so that Lite stays available. Propagating its status keeps that
+# contract rather than inventing one here.
+exit $LASTEXITCODE