patchloom 0.33.0

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
#Requires -Version 5.1
<#
.SYNOPSIS
  Windows / PowerShell host smoke for patchloom (create, replace, doc, tx, paths).

.DESCRIPTION
  Fixrealloop-style dogfood under PowerShell: nested relative paths (backslash
  form on Windows; forward-slash nested form on macOS/Linux pwsh), absolute
  paths, JSON error_kind peels, insert-after, contain, multi-op tx, rename
  --force overwrite, path-only binary rename (#2031), and hardlink
  sibling replace.
  Not part of make check. CI runs on ci-windows; local pwsh on macOS/Linux is
  supported for peels/tx/rename dogfood (Windows-only path spelling is gated).

.PARAMETER Bin
  Path to patchloom.exe (default: target\debug\patchloom.exe relative to repo root).

.EXAMPLE
  pwsh -File scripts/windows-smoke.ps1 -Bin target\debug\patchloom.exe
#>
param(
    [string]$Bin = ""
)

$ErrorActionPreference = "Stop"
$script:Passes = 0
$script:Fails = 0

function Pass([string]$Msg) {
    Write-Host "OK: $Msg"
    $script:Passes++
}
function Fail([string]$Msg) {
    Write-Host "FAIL: $Msg" -ForegroundColor Red
    $script:Fails++
}

# Resolve binary
$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
if (-not $Bin) {
    $candidates = @(
        (Join-Path $RepoRoot "target\debug\patchloom.exe"),
        (Join-Path $RepoRoot "target\release\patchloom.exe"),
        (Join-Path $RepoRoot "target\debug\patchloom"),
        (Join-Path $RepoRoot "target\release\patchloom")
    )
    foreach ($c in $candidates) {
        if (Test-Path -LiteralPath $c) { $Bin = $c; break }
    }
}
if (-not $Bin -or -not (Test-Path -LiteralPath $Bin)) {
    throw "patchloom binary not found. Build first (cargo build --all-features) or pass -Bin"
}
$Bin = (Resolve-Path -LiteralPath $Bin).Path
Write-Host "BIN=$Bin"

function Invoke-Pl {
    param(
        [Parameter(ValueFromRemainingArguments = $true)]
        [string[]]$Args
    )
    $out = & $Bin @Args 2>&1 | Out-String
    return @{
        ExitCode = $LASTEXITCODE
        Output   = $out
    }
}

function Get-JsonField {
    param([string]$JsonText, [string]$Name)
    try {
        $obj = $JsonText | ConvertFrom-Json
        $prop = $obj.PSObject.Properties[$Name]
        if ($null -eq $prop) { return $null }
        return $prop.Value
    } catch {
        return $null
    }
}

# Host OS: full backslash + drive-letter paths only on Windows. On macOS/Linux
# with pwsh (local dogfood of peels/tx/rename), use native nested paths so
# `make windows-smoke` is an honest pass without a Windows runner.
$IsWin = $false
if ($PSVersionTable.PSEdition -eq "Core") {
    $IsWin = [bool]$IsWindows
} else {
    $IsWin = ($env:OS -eq "Windows_NT")
}
Write-Host "HOST_WINDOWS=$IsWin"

$ws = Join-Path ([System.IO.Path]::GetTempPath()) ("pl-win-smoke-" + [guid]::NewGuid().ToString("N").Substring(0, 8))
New-Item -ItemType Directory -Path $ws | Out-Null
try {
    Push-Location $ws
    git init -q 2>$null | Out-Null
    git commit --allow-empty -q -m init 2>$null | Out-Null

    # --- create / already_exists / force ---
    Set-Content -Path (Join-Path $ws "taken.txt") -Value "old`n" -NoNewline
    $r = Invoke-Pl --json --cwd $ws create taken.txt --content "new"
    $kind = Get-JsonField $r.Output "error_kind"
    $applied = Get-JsonField $r.Output "applied"
    if ($r.ExitCode -eq 1 -and $kind -eq "already_exists" -and ($applied -eq $false -or "$applied" -eq "False")) {
        Pass "create dest-exists already_exists (PowerShell)"
    } else {
        $snip = if ($r.Output.Length -gt 200) { $r.Output.Substring(0, 200) } else { $r.Output }
        Fail "create dest-exists exit=$($r.ExitCode) kind=$kind applied=$applied out=$snip"
    }
    if ($r.Output -match "force") {
        Pass "already_exists message mentions force"
    } else {
        Fail "already_exists message missing force hint: $($r.Output)"
    }
    $r = Invoke-Pl --json --cwd $ws create taken.txt --content "forced`n" --force --apply
    $got = Get-Content -LiteralPath (Join-Path $ws "taken.txt") -Raw
    if ($r.ExitCode -eq 0 -and $got -match "forced") {
        Pass "create --force --apply"
    } else {
        Fail "create force exit=$($r.ExitCode) content=$got"
    }

    # --- nested relative path under --cwd (backslash form is Windows-agent style) ---
    $sub = Join-Path $ws (Join-Path "sub" "nested")
    New-Item -ItemType Directory -Path $sub -Force | Out-Null
    Set-Content -LiteralPath (Join-Path $sub "app.rs") -Value "use std::io;`nfn main() {}`n"
    if ($IsWin) {
        $rel = "sub\nested\app.rs"
        $pathLabel = "backslash relative path"
    } else {
        $rel = "sub/nested/app.rs"
        $pathLabel = "nested relative path (non-Windows pwsh)"
    }
    $r = Invoke-Pl --cwd $ws replace "use std::io;" --insert-after "use std::fs;" $rel --apply
    $body = Get-Content -LiteralPath (Join-Path $sub "app.rs") -Raw
    if ($r.ExitCode -eq 0 -and $body -match "use std::io;" -and $body -match "use std::fs;") {
        Pass "insert-after with $pathLabel"
    } else {
        Fail "insert-after exit=$($r.ExitCode) body=$body out=$($r.Output)"
    }

    # --- absolute path (native to this host) ---
    $absFile = Join-Path $ws "abs_target.txt"
    Set-Content -LiteralPath $absFile -Value "hello world`n"
    $r = Invoke-Pl replace "world" --new "windows" $absFile --apply
    $body = Get-Content -LiteralPath $absFile -Raw
    if ($r.ExitCode -eq 0 -and $body -match "hello windows") {
        Pass "replace via absolute path"
    } else {
        Fail "abs replace exit=$($r.ExitCode) body=$body"
    }

    # --- doc set on JSON ---
    $cfg = Join-Path $ws "config.json"
    Set-Content -LiteralPath $cfg -Value '{"server":{"port":8080}}' -NoNewline
    $r = Invoke-Pl --cwd $ws doc set config.json server.port 9090 --apply
    $cfgBody = Get-Content -LiteralPath $cfg -Raw
    if ($r.ExitCode -eq 0 -and $cfgBody -match "9090") {
        Pass "doc set"
    } else {
        Fail "doc set exit=$($r.ExitCode) body=$cfgBody"
    }

    # --- tx plan with key alias (ops + key) ---
    $plan = Join-Path $ws "plan.json"
    @'
{"ops":[{"op":"doc.set","path":"config.json","key":"server.port","value":7070}]}
'@ | Set-Content -LiteralPath $plan -NoNewline
    $r = Invoke-Pl --cwd $ws tx plan.json --apply
    $cfgBody = Get-Content -LiteralPath $cfg -Raw
    if ($r.ExitCode -eq 0 -and $cfgBody -match "7070") {
        Pass "tx plan key alias"
    } else {
        Fail "tx exit=$($r.ExitCode) body=$cfgBody out=$($r.Output)"
    }

    # --- multi-op tx create two files ---
    $plan2 = Join-Path $ws "plan2.json"
    @{
        version    = 1
        operations = @(
            @{ op = "file.create"; path = "t1.txt"; content = "a`n" }
            @{ op = "file.create"; path = "t2.txt"; content = "b`n" }
        )
    } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $plan2
    $r = Invoke-Pl --cwd $ws --json tx plan2.json --apply
    if ($r.ExitCode -eq 0 -and (Test-Path (Join-Path $ws "t1.txt")) -and (Test-Path (Join-Path $ws "t2.txt"))) {
        Pass "tx multi file.create"
    } else {
        Fail "tx multi exit=$($r.ExitCode) out=$($r.Output)"
    }

    # --- preview exit 2, no write ---
    Set-Content -LiteralPath (Join-Path $ws "prev.txt") -Value "alpha`n"
    $r = Invoke-Pl --cwd $ws replace alpha --new beta prev.txt
    $still = Get-Content -LiteralPath (Join-Path $ws "prev.txt") -Raw
    if ($r.ExitCode -eq 2 -and $still -match "alpha") {
        Pass "preview exit 2 no write"
    } else {
        Fail "preview exit=$($r.ExitCode) content=$still"
    }

    # --- --contain rejects escape (absolute path outside workspace) ---
    Set-Content -LiteralPath (Join-Path $ws "in.txt") -Value "x`n"
    # Path must exist so we hit PathGuard, not a not_found peel before the guard.
    if ($IsWin) {
        $escape = "C:\Windows\System32\drivers\etc\hosts"
    } else {
        $escape = "/etc/hosts"
        if (-not (Test-Path -LiteralPath $escape)) {
            $escape = "/"
        }
    }
    $r = Invoke-Pl --json --cwd $ws --contain read $escape
    $kind = Get-JsonField $r.Output "error_kind"
    if ($kind -eq "guard_rejected") {
        Pass "contain escape guard_rejected"
    } else {
        $snip = if ($r.Output.Length -gt 250) { $r.Output.Substring(0, 250) } else { $r.Output }
        Fail "contain kind=$kind exit=$($r.ExitCode) out=$snip"
    }

    # --- not_found delete ---
    $r = Invoke-Pl --json --cwd $ws delete missing-nope.txt --apply
    $kind = Get-JsonField $r.Output "error_kind"
    if ($kind -eq "not_found" -and $r.ExitCode -eq 1) {
        Pass "delete missing not_found"
    } else {
        Fail "delete kind=$kind exit=$($r.ExitCode)"
    }

    # --- rename dest already_exists ---
    Set-Content -LiteralPath (Join-Path $ws "from.txt") -Value "a`n"
    Set-Content -LiteralPath (Join-Path $ws "to.txt") -Value "b`n"
    $r = Invoke-Pl --json --cwd $ws rename from.txt to.txt --apply
    $kind = Get-JsonField $r.Output "error_kind"
    if ($kind -eq "already_exists") {
        Pass "rename dest already_exists"
    } else {
        Fail "rename kind=$kind out=$($r.Output)"
    }

    # --- rename --force overwrites destination ---
    $r = Invoke-Pl --json --cwd $ws rename from.txt to.txt --force --apply
    $to = Get-Content -LiteralPath (Join-Path $ws "to.txt") -Raw
    $fromExists = Test-Path -LiteralPath (Join-Path $ws "from.txt")
    if ($r.ExitCode -eq 0 -and $to -match "a" -and -not $fromExists) {
        Pass "rename --force overwrites dest"
    } else {
        Fail "rename force exit=$($r.ExitCode) to=$to fromExists=$fromExists out=$($r.Output)"
    }

    # --- replace keeps hardlink siblings in sync ---
    $hlA = Join-Path $ws "hl-a.txt"
    $hlB = Join-Path $ws "hl-b.txt"
    Set-Content -LiteralPath $hlA -Value "shared`n"
    try {
        New-Item -ItemType HardLink -Path $hlB -Target $hlA | Out-Null
        $r = Invoke-Pl --json --cwd $ws replace shared --new CHANGED hl-a.txt --apply
        $aTxt = Get-Content -LiteralPath $hlA -Raw
        $bTxt = Get-Content -LiteralPath $hlB -Raw
        if ($r.ExitCode -eq 0 -and $aTxt -match "CHANGED" -and $bTxt -match "CHANGED") {
            Pass "replace preserves hardlink siblings"
        } else {
            Fail "hardlink replace exit=$($r.ExitCode) a=$aTxt b=$bTxt out=$($r.Output)"
        }
    } catch {
        Fail "hardlink setup failed: $_"
    }

    # --- replace via 8.3 name keeps the long directory entry ---
    if ($IsWin) {
        $long8 = Join-Path $ws "LongFileName.txt"
        Set-Content -LiteralPath $long8 -Value "old8`n" -NoNewline
        try {
            $fso = New-Object -ComObject Scripting.FileSystemObject
            $short8 = $fso.GetFile($long8).ShortPath
            $shortLeaf = if ($short8) { Split-Path -Leaf $short8 } else { "" }
            if ($shortLeaf -and ($shortLeaf -match '~')) {
                $r = Invoke-Pl --json --cwd $ws replace old8 --new NEW8 $shortLeaf --apply
                $longLeft = Test-Path -LiteralPath $long8
                $longBody = if ($longLeft) { Get-Content -LiteralPath $long8 -Raw } else { "" }
                $extraShort = Test-Path -LiteralPath (Join-Path $ws $shortLeaf)
                if ($r.ExitCode -eq 0 -and $longLeft -and $longBody -match "NEW8") {
                    Pass "replace via 8.3 keeps long name"
                } else {
                    Fail "8.3 replace exit=$($r.ExitCode) longLeft=$longLeft extra=$extraShort body=$longBody out=$($r.Output)"
                }
            } else {
                Write-Host "SKIP: 8.3 short names disabled"
            }
        } catch {
            Write-Host "SKIP: 8.3 probe failed ($_)"
        }
    }

    # --- path-only binary rename (NUL byte; #2031 host contract on Windows) ---
    $binSrc = Join-Path $ws "blob.bin"
    $binDst = Join-Path $ws "blob-moved.bin"
    [System.IO.File]::WriteAllBytes($binSrc, [byte[]](0x00, 0x01, 0x02, 0xff))
    $r = Invoke-Pl --json --cwd $ws rename blob.bin blob-moved.bin --apply
    $dstBytes = if (Test-Path -LiteralPath $binDst) { [System.IO.File]::ReadAllBytes($binDst) } else { @() }
    $srcGone = -not (Test-Path -LiteralPath $binSrc)
    if ($r.ExitCode -eq 0 -and $srcGone -and $dstBytes.Length -eq 4 -and $dstBytes[0] -eq 0) {
        Pass "binary rename path-only preserves bytes"
    } else {
        $snip = if ($r.Output.Length -gt 200) { $r.Output.Substring(0, 200) } else { $r.Output }
        Fail "binary rename exit=$($r.ExitCode) srcGone=$srcGone len=$($dstBytes.Length) out=$snip"
    }

    # --- batch PATH OLD NEW ---
    Set-Content -LiteralPath (Join-Path $ws "ver.txt") -Value "v1`n"
    $ops = Join-Path $ws "ops.txt"
    "replace ver.txt v1 v2" | Set-Content -LiteralPath $ops
    $r = Invoke-Pl --cwd $ws batch ops.txt --apply
    $v = Get-Content -LiteralPath (Join-Path $ws "ver.txt") -Raw
    if ($r.ExitCode -eq 0 -and $v -match "v2") {
        Pass "batch replace PATH OLD NEW"
    } else {
        Fail "batch exit=$($r.ExitCode) ver=$v"
    }

    # --- search no_matches exit 3 ---
    Set-Content -LiteralPath (Join-Path $ws "only.txt") -Value "only`n"
    $r = Invoke-Pl --json --cwd $ws search nomatch only.txt
    if ($r.ExitCode -eq 3) {
        Pass "search no_matches exit 3"
    } else {
        Fail "search exit=$($r.ExitCode)"
    }

    # --- CRLF file insert-after keeps CR LF ---
    $crlf = Join-Path $ws "crlf.txt"
    [System.IO.File]::WriteAllBytes($crlf, [byte[]](0x6c, 0x31, 0x0d, 0x0a, 0x6c, 0x32, 0x0d, 0x0a)) # l1\r\nl2\r\n
    $r = Invoke-Pl --cwd $ws replace "l1" --insert-after "// post" crlf.txt --apply
    $bytes = [System.IO.File]::ReadAllBytes($crlf)
    $hex = ($bytes | ForEach-Object { $_.ToString("x2") }) -join " "
    # Expect l1\r\n// post\r\n...
    $asText = [System.Text.Encoding]::UTF8.GetString($bytes)
    if ($r.ExitCode -eq 0 -and $asText -match "l1\r\n// post\r\n") {
        Pass "CRLF insert-after preserves line endings"
    } elseif ($r.ExitCode -eq 0 -and $asText -match "// post") {
        # Still applied; mixed endings would be a product bug
        if ($asText -match "l1\n// post" -and $asText -notmatch "l1\r\n// post") {
            Fail "CRLF insert used bare LF: $hex"
        } else {
            Pass "CRLF insert-after applied ($hex)"
        }
    } else {
        Fail "CRLF exit=$($r.ExitCode) hex=$hex"
    }

    # --- delete directory junction unlinks the link, not the target ---
    if ($IsWin) {
        $jreal = Join-Path $ws "jreal"
        $jlink = Join-Path $ws "jlink"
        New-Item -ItemType Directory -Path $jreal | Out-Null
        Set-Content -LiteralPath (Join-Path $jreal "keep.txt") -Value "keep`n" -NoNewline
        $mklink = cmd /c mklink /J $jlink $jreal
        if ($LASTEXITCODE -eq 0) {
            $r = Invoke-Pl --json --cwd $ws delete jlink --apply
            $keep = Test-Path -LiteralPath (Join-Path $jreal "keep.txt")
            $linkLeft = Test-Path -LiteralPath $jlink
            $jApplied = Get-JsonField $r.Output "applied"
            if ($r.ExitCode -eq 0 -and $keep -and -not $linkLeft -and ("$jApplied" -eq "True" -or $jApplied -eq $true)) {
                Pass "delete junction unlinks link not target"
            } else {
                $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
                Fail "junction delete exit=$($r.ExitCode) keep=$keep linkLeft=$linkLeft applied=$jApplied out=$snip"
            }
        } else {
            Write-Host "SKIP: mklink /J failed ($mklink)"
        }

        # Name with `&` must unlink the reparse point (not become cmd operators).
        $jampReal = Join-Path $ws "jamp-real"
        $jampLink = Join-Path $ws "jamp&echo"
        New-Item -ItemType Directory -Path $jampReal | Out-Null
        Set-Content -LiteralPath (Join-Path $jampReal "keep.txt") -Value "keep`n" -NoNewline
        try {
            New-Item -ItemType Junction -LiteralPath $jampLink -Target $jampReal | Out-Null
            $r = Invoke-Pl --json --cwd $ws delete "jamp&echo" --apply
            $keep = Test-Path -LiteralPath (Join-Path $jampReal "keep.txt")
            $linkLeft = Test-Path -LiteralPath $jampLink
            $jApplied = Get-JsonField $r.Output "applied"
            if ($r.ExitCode -eq 0 -and $keep -and -not $linkLeft -and ("$jApplied" -eq "True" -or $jApplied -eq $true)) {
                Pass "delete junction with & unlinks link not target"
            } else {
                $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
                Fail "junction & delete exit=$($r.ExitCode) keep=$keep linkLeft=$linkLeft applied=$jApplied out=$snip"
            }
        } catch {
            Write-Host "SKIP: junction with & create failed ($_)"
        }
    }

    # --- readonly replace fail-restore is applied:false (Windows attribute) ---
    if ($IsWin) {
        $ro = Join-Path $ws "ro-win.txt"
        Set-Content -LiteralPath $ro -Value "hello-win`n" -NoNewline
        attrib +R $ro
        $r = Invoke-Pl --json --cwd $ws replace hello-win --new HELLO-WIN ro-win.txt --apply
        $roKind = Get-JsonField $r.Output "error_kind"
        $roApplied = Get-JsonField $r.Output "applied"
        $roBody = Get-Content -LiteralPath $ro -Raw
        attrib -R $ro
        if ($r.ExitCode -eq 1 -and $roKind -eq "rollback_failed" -and ("$roApplied" -eq "False" -or $roApplied -eq $false) -and $roBody -match "hello-win") {
            Pass "readonly replace fail-restore applied:false"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "readonly replace exit=$($r.ExitCode) kind=$roKind applied=$roApplied body=$roBody out=$snip"
        }
    }

    # --- ADS create is invalid_input, not applied ---
    if ($IsWin) {
        $r = Invoke-Pl --json --cwd $ws create "ads.txt:stream" --content x --apply
        $adsKind = Get-JsonField $r.Output "error_kind"
        $adsApplied = Get-JsonField $r.Output "applied"
        if ($r.ExitCode -eq 1 -and $adsKind -eq "invalid_input" -and ("$adsApplied" -ne "True")) {
            Pass "ADS create invalid_input not applied"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "ADS create exit=$($r.ExitCode) kind=$adsKind applied=$adsApplied out=$snip"
        }

        $r = Invoke-Pl --json --cwd $ws create "bad<name.txt" --content x --apply
        $badKind = Get-JsonField $r.Output "error_kind"
        $badApplied = Get-JsonField $r.Output "applied"
        if ($r.ExitCode -eq 1 -and $badKind -eq "invalid_input" -and ("$badApplied" -ne "True")) {
            Pass "illegal dest create invalid_input not applied"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "illegal dest exit=$($r.ExitCode) kind=$badKind applied=$badApplied out=$snip"
        }

        Set-Content -LiteralPath (Join-Path $ws "file.txt") -Value "KEEP`n" -NoNewline
        $r = Invoke-Pl --json --cwd $ws create "file.txt " --content OVER --force --apply
        $spKind = Get-JsonField $r.Output "error_kind"
        $spApplied = Get-JsonField $r.Output "applied"
        $kept = (Get-Content -LiteralPath (Join-Path $ws "file.txt") -Raw)
        if ($r.ExitCode -eq 1 -and $spKind -eq "invalid_input" -and ("$spApplied" -ne "True") -and $kept -eq "KEEP`n") {
            Pass "trailing-space dest invalid_input does not overwrite"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "trailing-space dest exit=$($r.ExitCode) kind=$spKind applied=$spApplied body=$kept out=$snip"
        }
    }

    # --- --glob *.txt matches Hit.TXT (Windows case-insensitive names) ---
    if ($IsWin) {
        Set-Content -LiteralPath (Join-Path $ws "Hit.TXT") -Value "needle`n" -NoNewline
        $r = Invoke-Pl --json --cwd $ws --glob "*.txt" search needle
        $gCount = Get-JsonField $r.Output "match_count"
        if ($r.ExitCode -eq 0 -and "$gCount" -eq "1") {
            Pass "glob *.txt matches Hit.TXT"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "glob case exit=$($r.ExitCode) count=$gCount out=$snip"
        }
    }

    # --- .gitignore *.log drops app.LOG (git core.ignorecase on Windows) ---
    if ($IsWin) {
        $ig = Join-Path $ws "igcase"
        New-Item -ItemType Directory -Path (Join-Path $ig ".git") | Out-Null
        Set-Content -LiteralPath (Join-Path $ig ".gitignore") -Value "*.log`n" -NoNewline
        Set-Content -LiteralPath (Join-Path $ig "app.LOG") -Value "needle`n" -NoNewline
        Set-Content -LiteralPath (Join-Path $ig "keep.txt") -Value "needle`n" -NoNewline
        $r = Invoke-Pl --json --cwd $ig search needle
        $igCount = Get-JsonField $r.Output "match_count"
        $igOut = $r.Output
        if ($r.ExitCode -eq 0 -and "$igCount" -eq "1" -and ($igOut -notmatch "(?i)app\.log")) {
            Pass "gitignore *.log drops app.LOG"
        } else {
            $snip = if ($igOut.Length -gt 240) { $igOut.Substring(0, 240) } else { $igOut }
            Fail "gitignore case exit=$($r.ExitCode) count=$igCount out=$snip"
        }
    }

    # --- --contain accepts in-workspace \\localhost\C$\... ---
    if ($IsWin) {
        $uncFile = Join-Path $ws "unc-in.txt"
        Set-Content -LiteralPath $uncFile -Value "x`n" -NoNewline
        $drive = $ws.Substring(0, 1)
        $rest = $ws.Substring(2).TrimStart('\')
        $unc = "\\localhost\${drive}`$\${rest}\unc-in.txt"
        $r = Invoke-Pl --json --cwd $ws --contain replace x --new y $unc --apply
        $uncBody = Get-Content -LiteralPath $uncFile -Raw
        if ($r.ExitCode -eq 0 -and $uncBody -eq "y`n") {
            Pass "contain localhost C`$ in-workspace"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "UNC contain exit=$($r.ExitCode) body=$uncBody out=$snip"
        }

        $unc6File = Join-Path $ws "unc-v6.txt"
        Set-Content -LiteralPath $unc6File -Value "x`n" -NoNewline
        $unc6 = "\\[::1]\${drive}`$\${rest}\unc-v6.txt"
        $r6 = Invoke-Pl --json --cwd $ws --contain replace x --new y $unc6 --apply
        $unc6Body = Get-Content -LiteralPath $unc6File -Raw
        if ($r6.ExitCode -eq 0 -and $unc6Body -eq "y`n") {
            Pass "contain IPv6 loopback C`$ in-workspace"
        } else {
            $snip = if ($r6.Output.Length -gt 240) { $r6.Output.Substring(0, 240) } else { $r6.Output }
            Fail "IPv6 UNC contain exit=$($r6.ExitCode) body=$unc6Body out=$snip"
        }
    }

    # --- create dest past MAX_PATH without \\?\ ---
    if ($IsWin) {
        $longLeaf = ("L" * 240) + ".txt"
        $longDest = Join-Path $ws $longLeaf
        $r = Invoke-Pl --json --cwd $ws create $longDest --content "hi`n" --apply
        $longBody = $null
        try {
            $longBody = [System.IO.File]::ReadAllText("\\?\$longDest")
        } catch {
            $longBody = $null
        }
        if ($r.ExitCode -eq 0 -and $longBody -eq "hi`n") {
            Pass "create long dest without verbatim prefix"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "long create exit=$($r.ExitCode) body=$longBody out=$snip"
        }
    }

    # --- replace keeps Zone.Identifier (MOTW) through atomic persist ---
    if ($IsWin) {
        $motw = Join-Path $ws "motw.txt"
        Set-Content -LiteralPath $motw -Value "old line`n" -NoNewline
        Set-Content -LiteralPath "${motw}:Zone.Identifier" -Value "[ZoneTransfer]`r`nZoneId=3`r`n" -NoNewline
        $r = Invoke-Pl --json --cwd $ws replace old --new new motw.txt --apply
        $motwBody = Get-Content -LiteralPath $motw -Raw
        $zone = $null
        try { $zone = Get-Content -LiteralPath $motw -Stream Zone.Identifier -Raw -ErrorAction Stop } catch { $zone = $null }
        if ($r.ExitCode -eq 0 -and $motwBody -match "new line" -and $zone -match "ZoneId=3") {
            Pass "replace keeps Zone.Identifier MOTW"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "MOTW persist exit=$($r.ExitCode) body=$motwBody zone=$zone out=$snip"
        }

        $ads = Join-Path $ws "ads.txt"
        Set-Content -LiteralPath $ads -Value "old line`n" -NoNewline
        Set-Content -LiteralPath "${ads}:custom" -Value "secret" -NoNewline
        Set-Content -LiteralPath "${ads}:Zone.Identifier" -Value "[ZoneTransfer]`r`nZoneId=3`r`n" -NoNewline
        $r = Invoke-Pl --json --cwd $ws replace old --new new ads.txt --apply
        $adsBody = Get-Content -LiteralPath $ads -Raw
        $custom = $null
        $adsZone = $null
        try { $custom = Get-Content -LiteralPath $ads -Stream custom -Raw -ErrorAction Stop } catch { $custom = $null }
        try { $adsZone = Get-Content -LiteralPath $ads -Stream Zone.Identifier -Raw -ErrorAction Stop } catch { $adsZone = $null }
        if ($r.ExitCode -eq 0 -and $adsBody -match "new line" -and $custom -match "secret" -and $adsZone -match "ZoneId=3") {
            Pass "replace keeps custom named stream and MOTW"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "custom ADS persist exit=$($r.ExitCode) body=$adsBody custom=$custom zone=$adsZone out=$snip"
        }
    }

    # --- doc set JSON with UTF-8 BOM (Notepad/VS) ---
    if ($IsWin) {
        $bomJson = Join-Path $ws "bom.json"
        [System.IO.File]::WriteAllBytes($bomJson, [byte[]](0xEF, 0xBB, 0xBF) + [Text.Encoding]::UTF8.GetBytes("{`"k`":1}`n"))
        $r = Invoke-Pl --json --cwd $ws doc set bom.json k 2 --apply
        $bomBody = [System.IO.File]::ReadAllText($bomJson)
        if ($r.ExitCode -eq 0 -and $bomBody -match '"k"\s*:\s*2') {
            Pass "doc set JSON UTF-8 BOM"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "doc BOM JSON exit=$($r.ExitCode) body=$bomBody out=$snip"
        }
    }

    # --- doc get multi-doc YAML with UTF-8 BOM (Notepad --- first) ---
    if ($IsWin) {
        $bomYaml = Join-Path $ws "bom-multi.yaml"
        [System.IO.File]::WriteAllBytes($bomYaml, [byte[]](0xEF, 0xBB, 0xBF) + [Text.Encoding]::UTF8.GetBytes("---`r`na: 1`r`n---`r`nb: 2`r`n"))
        $r = Invoke-Pl --json --cwd $ws doc get bom-multi.yaml 0.a
        if ($r.ExitCode -eq 0 -and $r.Output -match '"value"\s*:\s*1') {
            Pass "doc get YAML multi-doc UTF-8 BOM"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "doc BOM YAML multi-doc exit=$($r.ExitCode) out=$snip"
        }
    }

    # --- batch file with UTF-8 BOM (Notepad/VS) ---
    if ($IsWin) {
        $bomHit = Join-Path $ws "bom-batch.txt"
        Set-Content -LiteralPath $bomHit -Value "old`n" -NoNewline
        $bomBat = Join-Path $ws "bom-ops.batch"
        [System.IO.File]::WriteAllBytes($bomBat, [byte[]](0xEF, 0xBB, 0xBF) + [Text.Encoding]::UTF8.GetBytes("replace bom-batch.txt old new`n"))
        $r = Invoke-Pl --json --cwd $ws batch $bomBat --apply
        $hitBody = [System.IO.File]::ReadAllText($bomHit)
        if ($r.ExitCode -eq 0 -and $hitBody -eq "new`n") {
            Pass "batch file UTF-8 BOM"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "batch BOM exit=$($r.ExitCode) body=$hitBody out=$snip"
        }
    }

    # --- tx JSON plan with UTF-8 BOM (Notepad/VS) ---
    if ($IsWin) {
        $bomHit = Join-Path $ws "bom-tx.txt"
        Set-Content -LiteralPath $bomHit -Value "old`n" -NoNewline
        $bomPlan = Join-Path $ws "bom-plan.json"
        $planJson = "{`"ops`":[{`"op`":`"replace`",`"path`":`"bom-tx.txt`",`"old`":`"old`",`"new`":`"new`"}]}"
        [System.IO.File]::WriteAllBytes($bomPlan, [byte[]](0xEF, 0xBB, 0xBF) + [Text.Encoding]::UTF8.GetBytes($planJson))
        $r = Invoke-Pl --json --cwd $ws tx $bomPlan --apply
        $hitBody = [System.IO.File]::ReadAllText($bomHit)
        if ($r.ExitCode -eq 0 -and $hitBody -eq "new`n") {
            Pass "tx JSON plan UTF-8 BOM"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "tx BOM plan exit=$($r.ExitCode) body=$hitBody out=$snip"
        }
    }

    # --- replace via //?/C:/... (CopyFile rejects that spelling) ---
    if ($IsWin) {
        # Isolated dir so we do not pick an earlier session's manifest
        # (Get-ChildItem on the shared $ws first hit taken.txt).
        $fwdWs = Join-Path $ws "fwd-prefix"
        New-Item -ItemType Directory -Path $fwdWs | Out-Null
        $fwdHit = Join-Path $fwdWs "fwd.txt"
        Set-Content -LiteralPath $fwdHit -Value "old`n" -NoNewline
        $fwd = "//?/" + ($fwdHit -replace '\\', '/')
        Push-Location $fwdWs
        try {
            $r = Invoke-Pl --json replace old --new new --apply $fwd
        } finally {
            Pop-Location
        }
        $fwdBody = [System.IO.File]::ReadAllText($fwdHit)
        $rel = $null
        $manifests = Get-ChildItem -Path (Join-Path $fwdWs ".patchloom\backups") -Filter manifest.json -Recurse -ErrorAction SilentlyContinue
        if ($manifests) {
            $rel = (Get-Content -Raw $manifests[0].FullName | ConvertFrom-Json).entries[0].path
        }
        if ($r.ExitCode -eq 0 -and $fwdBody -eq "new`n" -and $rel -eq "fwd.txt") {
            Pass "replace forward extended prefix"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "forward extended replace exit=$($r.ExitCode) body=$fwdBody rel=$rel out=$snip"
        }

        $cWs = Join-Path $ws "fwd-contain"
        New-Item -ItemType Directory -Path $cWs | Out-Null
        $cHit = Join-Path $cWs "con.txt"
        Set-Content -LiteralPath $cHit -Value "in`n" -NoNewline
        $cFwd = "//?/" + ($cHit -replace '\\', '/')
        Push-Location $cWs
        try {
            $r = Invoke-Pl --json --contain replace in --new out --apply $cFwd
        } finally {
            Pop-Location
        }
        $cBody = [System.IO.File]::ReadAllText($cHit)
        if ($r.ExitCode -eq 0 -and $cBody -eq "out`n") {
            Pass "contain forward extended prefix in-workspace"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "contain //?/ in-ws exit=$($r.ExitCode) body=$cBody out=$snip"
        }

        $dWs = Join-Path $ws "dot-device"
        New-Item -ItemType Directory -Path $dWs | Out-Null
        $dHit = Join-Path $dWs "dev.txt"
        Set-Content -LiteralPath $dHit -Value "old`n" -NoNewline
        $dDev = "\\.\" + $dHit
        Push-Location $dWs
        try {
            $r = Invoke-Pl --json replace old --new new --apply $dDev
        } finally {
            Pop-Location
        }
        $dBody = [System.IO.File]::ReadAllText($dHit)
        if ($r.ExitCode -eq 0 -and $dBody -eq "new`n") {
            Pass "replace dot-device drive dest"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "dot-device replace exit=$($r.ExitCode) body=$dBody out=$snip"
        }

        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        $r = Invoke-Pl --json read '\\.\CON'
        $sw.Stop()
        if ($r.ExitCode -ne 0 -and $r.Output -match "not a file name|invalid_input" -and $sw.Elapsed.TotalSeconds -lt 15) {
            Pass "read CON device dest refused"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "read CON exit=$($r.ExitCode) ms=$($sw.Elapsed.TotalMilliseconds) out=$snip"
        }

        $r = Invoke-Pl --json create '\\.\NUL' --content x --apply
        if ($r.ExitCode -ne 0 -and $r.Output -match "not a file name|invalid_input") {
            Pass "create NUL device dest refused"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "create NUL exit=$($r.ExitCode) out=$snip"
        }

        $r = Invoke-Pl --json create NUL --content x --apply
        if ($r.ExitCode -ne 0 -and $r.Output -match "not a file name|invalid_input") {
            Pass "create bare NUL dest refused"
        } else {
            $snip = if ($r.Output.Length -gt 240) { $r.Output.Substring(0, 240) } else { $r.Output }
            Fail "create bare NUL exit=$($r.ExitCode) out=$snip"
        }
    }

    # --- version ---
    $r = Invoke-Pl --version
    if ($r.ExitCode -eq 0 -and $r.Output -match "patchloom") {
        Pass "version"
    } else {
        Fail "version: $($r.Output)"
    }

} finally {
    Pop-Location
    Remove-Item -LiteralPath $ws -Recurse -Force -ErrorAction SilentlyContinue
}

Write-Host "==== windows-smoke: passes=$script:Passes fails=$script:Fails ===="
if ($script:Fails -gt 0) {
    exit 1
}
exit 0