planning-with-files 3.11.2 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "planning-with-files-pi-extension",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -176,13 +176,13 @@ function runSessionCatchup(cwd: string): ExecResult {
176
176
  return { ok: false, stdout: "", stderr: `missing catchup script: ${CATCHUP_SCRIPT}` };
177
177
  }
178
178
 
179
- return runFirstSuccessful(
180
- [
181
- ["uv", ["run", CATCHUP_SCRIPT, cwd]],
182
- ["python3", [CATCHUP_SCRIPT, cwd]],
183
- ["python", [CATCHUP_SCRIPT, cwd]],
184
- ["py", ["-3", CATCHUP_SCRIPT, cwd]],
185
- ],
179
+ return runFirstSuccessful(
180
+ [
181
+ ["uv", ["run", CATCHUP_SCRIPT, "--no-history", cwd]],
182
+ ["python3", [CATCHUP_SCRIPT, "--no-history", cwd]],
183
+ ["python", [CATCHUP_SCRIPT, "--no-history", cwd]],
184
+ ["py", ["-3", CATCHUP_SCRIPT, "--no-history", cwd]],
185
+ ],
186
186
  cwd,
187
187
  );
188
188
  }
package/package.json CHANGED
@@ -1,53 +1,56 @@
1
- {
2
- "name": "planning-with-files",
3
- "version": "3.11.2",
4
- "description": "Persistent file-based planning for AI coding agents: keeps task_plan.md, findings.md and progress.md on disk so the plan survives /clear, context loss and compaction. Manus-style. Ships the skill plus a Pi Coding Agent extension.",
5
- "keywords": [
6
- "pi-package",
7
- "pi-skill",
8
- "planning",
9
- "manus",
10
- "agent",
11
- "agent-skills",
12
- "claude-code",
13
- "claude-skills",
14
- "coding-agent",
15
- "context-engineering",
16
- "session-recovery",
17
- "long-running-agents"
18
- ],
19
- "pi": {
20
- "skills": [
21
- "SKILL.md"
22
- ],
23
- "extensions": [
24
- "extensions/planning-with-files/index.ts"
25
- ]
26
- },
27
- "files": [
28
- "README.md",
29
- "SKILL.md",
30
- "examples.md",
31
- "reference.md",
32
- "scripts/",
33
- "templates/",
34
- "extensions/",
35
- "!**/node_modules",
36
- "!**/__pycache__",
37
- "!**/*.pyc",
38
- "!extensions/planning-with-files/package-lock.json"
39
- ],
40
- "peerDependencies": {
41
- "@earendil-works/pi-coding-agent": "*"
42
- },
43
- "repository": {
44
- "type": "git",
45
- "url": "git+https://github.com/OthmanAdi/planning-with-files.git"
46
- },
47
- "author": "Ahmad Othman Ammar Adi",
48
- "license": "MIT",
49
- "bugs": {
50
- "url": "https://github.com/OthmanAdi/planning-with-files/issues"
51
- },
52
- "homepage": "https://github.com/OthmanAdi/planning-with-files#readme"
53
- }
1
+ {
2
+ "name": "planning-with-files",
3
+ "version": "3.14.0",
4
+ "description": "Persistent project planning with selected context injection. Automatic recovery uses project files only; explicit catchup modes read same-project local session records for aggregate counts or bounded replay. The host-aware gate never runs Markdown-declared commands. No network upload path. Ships the skill plus a Pi Coding Agent extension.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-skill",
8
+ "planning",
9
+ "manus",
10
+ "agent",
11
+ "agent-skills",
12
+ "claude-code",
13
+ "claude-skills",
14
+ "coding-agent",
15
+ "context-engineering",
16
+ "session-recovery",
17
+ "long-running-agents"
18
+ ],
19
+ "pi": {
20
+ "skills": [
21
+ "SKILL.md"
22
+ ],
23
+ "extensions": [
24
+ "extensions/planning-with-files/index.ts"
25
+ ]
26
+ },
27
+ "scripts": {
28
+ "prepack": "node scripts/verify-shell-line-endings.mjs"
29
+ },
30
+ "files": [
31
+ "README.md",
32
+ "SKILL.md",
33
+ "examples.md",
34
+ "reference.md",
35
+ "scripts/",
36
+ "templates/",
37
+ "extensions/",
38
+ "!**/node_modules",
39
+ "!**/__pycache__",
40
+ "!**/*.pyc",
41
+ "!extensions/planning-with-files/package-lock.json"
42
+ ],
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": "*"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/OthmanAdi/planning-with-files.git"
49
+ },
50
+ "author": "Ahmad Othman Ammar Adi",
51
+ "license": "MIT",
52
+ "bugs": {
53
+ "url": "https://github.com/OthmanAdi/planning-with-files/issues"
54
+ },
55
+ "homepage": "https://github.com/OthmanAdi/planning-with-files#readme"
56
+ }
@@ -31,42 +31,399 @@ param(
31
31
 
32
32
  $ErrorActionPreference = "Stop"
33
33
 
34
- function Resolve-PlanFile {
35
- $planRoot = Join-Path (Get-Location) ".planning"
34
+ $script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
35
+ if (-not $script:IsWindowsHost) {
36
+ throw "Safe no-follow descriptor operations are unavailable in this PowerShell script on Unix. Use scripts/attest-plan.sh instead."
37
+ }
38
+ if ($script:IsWindowsHost -and -not ("PwfAttestationNative" -as [type])) {
39
+ Add-Type -TypeDefinition @'
40
+ using System;
41
+ using System.ComponentModel;
42
+ using System.IO;
43
+ using System.Runtime.InteropServices;
44
+ using System.Text;
45
+ using Microsoft.Win32.SafeHandles;
46
+
47
+ public static class PwfAttestationNative {
48
+ private const uint GENERIC_READ = 0x80000000;
49
+ private const uint GENERIC_WRITE = 0x40000000;
50
+ private const uint DELETE = 0x00010000;
51
+ private const uint FILE_READ_ATTRIBUTES = 0x00000080;
52
+ private const uint FILE_SHARE_READ = 0x00000001;
53
+ private const uint FILE_SHARE_WRITE = 0x00000002;
54
+ private const uint FILE_SHARE_DELETE = 0x00000004;
55
+ private const uint CREATE_NEW = 1;
56
+ private const uint OPEN_EXISTING = 3;
57
+ private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
58
+ private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
59
+ private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400;
60
+ private const int FileAttributeTagInfo = 9;
61
+ private const int FileDispositionInfo = 4;
62
+ private const int ERROR_FILE_EXISTS = 80;
63
+ private const int ERROR_ALREADY_EXISTS = 183;
64
+
65
+ [StructLayout(LayoutKind.Sequential)]
66
+ private struct FILE_ATTRIBUTE_TAG_INFO {
67
+ public uint FileAttributes;
68
+ public uint ReparseTag;
69
+ }
70
+
71
+ [StructLayout(LayoutKind.Sequential)]
72
+ private struct BY_HANDLE_FILE_INFORMATION {
73
+ public uint FileAttributes;
74
+ public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
75
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
76
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
77
+ public uint VolumeSerialNumber;
78
+ public uint FileSizeHigh;
79
+ public uint FileSizeLow;
80
+ public uint NumberOfLinks;
81
+ public uint FileIndexHigh;
82
+ public uint FileIndexLow;
83
+ }
84
+
85
+ [StructLayout(LayoutKind.Sequential)]
86
+ private struct FILE_DISPOSITION_INFO {
87
+ [MarshalAs(UnmanagedType.Bool)] public bool DeleteFile;
88
+ }
89
+
90
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
91
+ private static extern SafeFileHandle CreateFileW(
92
+ string name, uint access, uint share, IntPtr security,
93
+ uint creation, uint flags, IntPtr template);
94
+
95
+ [DllImport("kernel32.dll", SetLastError = true)]
96
+ private static extern bool GetFileInformationByHandleEx(
97
+ SafeFileHandle handle, int infoClass,
98
+ out FILE_ATTRIBUTE_TAG_INFO info, uint size);
99
+
100
+ [DllImport("kernel32.dll", SetLastError = true)]
101
+ private static extern bool GetFileInformationByHandle(
102
+ SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION info);
103
+
104
+ [DllImport("kernel32.dll", SetLastError = true)]
105
+ private static extern bool SetFileInformationByHandle(
106
+ SafeFileHandle handle, int infoClass,
107
+ ref FILE_DISPOSITION_INFO info, uint size);
108
+
109
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
110
+ private static extern uint GetFinalPathNameByHandleW(
111
+ SafeFileHandle handle, StringBuilder path, uint length, uint flags);
112
+
113
+ private static void ValidateRegular(SafeFileHandle handle, bool singleLink) {
114
+ FILE_ATTRIBUTE_TAG_INFO tag;
115
+ if (!GetFileInformationByHandleEx(
116
+ handle, FileAttributeTagInfo, out tag,
117
+ (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
118
+ throw new Win32Exception(Marshal.GetLastWin32Error());
119
+ if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
120
+ throw new IOException("Refusing a reparse-point file.");
121
+ if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
122
+ throw new IOException("Refusing a directory where a regular file is required.");
123
+ if (singleLink) {
124
+ BY_HANDLE_FILE_INFORMATION info;
125
+ if (!GetFileInformationByHandle(handle, out info))
126
+ throw new Win32Exception(Marshal.GetLastWin32Error());
127
+ if (info.NumberOfLinks != 1)
128
+ throw new IOException("Refusing a multiply-linked attestation file.");
129
+ }
130
+ }
131
+
132
+ public static SafeFileHandle OpenRead(string path, bool singleLink) {
133
+ SafeFileHandle handle = CreateFileW(
134
+ path, GENERIC_READ | FILE_READ_ATTRIBUTES,
135
+ FILE_SHARE_READ, IntPtr.Zero, OPEN_EXISTING,
136
+ FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
137
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
138
+ try { ValidateRegular(handle, singleLink); return handle; }
139
+ catch { handle.Dispose(); throw; }
140
+ }
141
+
142
+ public static SafeFileHandle OpenAttestationWrite(string path) {
143
+ uint access = GENERIC_READ | GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES;
144
+ SafeFileHandle handle = CreateFileW(
145
+ path, access, 0, IntPtr.Zero, CREATE_NEW,
146
+ FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
147
+ if (handle.IsInvalid) {
148
+ int error = Marshal.GetLastWin32Error();
149
+ if (error != ERROR_FILE_EXISTS && error != ERROR_ALREADY_EXISTS)
150
+ throw new Win32Exception(error);
151
+ handle = CreateFileW(
152
+ path, access, 0, IntPtr.Zero, OPEN_EXISTING,
153
+ FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
154
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
155
+ }
156
+ try { ValidateRegular(handle, true); return handle; }
157
+ catch { handle.Dispose(); throw; }
158
+ }
159
+
160
+ public static SafeFileHandle OpenDelete(string path) {
161
+ SafeFileHandle handle = CreateFileW(
162
+ path, DELETE | FILE_READ_ATTRIBUTES, 0, IntPtr.Zero, OPEN_EXISTING,
163
+ FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
164
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
165
+ try { ValidateRegular(handle, true); return handle; }
166
+ catch { handle.Dispose(); throw; }
167
+ }
168
+
169
+ public static void DeleteOpened(SafeFileHandle handle) {
170
+ FILE_DISPOSITION_INFO info = new FILE_DISPOSITION_INFO { DeleteFile = true };
171
+ if (!SetFileInformationByHandle(
172
+ handle, FileDispositionInfo, ref info,
173
+ (uint)Marshal.SizeOf(typeof(FILE_DISPOSITION_INFO))))
174
+ throw new Win32Exception(Marshal.GetLastWin32Error());
175
+ }
176
+
177
+ public static string FinalPath(SafeFileHandle handle) {
178
+ StringBuilder buffer = new StringBuilder(32768);
179
+ uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
180
+ if (length == 0 || length >= buffer.Capacity)
181
+ throw new Win32Exception(Marshal.GetLastWin32Error());
182
+ string result = buffer.ToString();
183
+ if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
184
+ return @"\\" + result.Substring(8);
185
+ if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
186
+ return result.Substring(4);
187
+ return result;
188
+ }
189
+
190
+ public static string FileIdentity(SafeFileHandle handle) {
191
+ BY_HANDLE_FILE_INFORMATION info;
192
+ if (!GetFileInformationByHandle(handle, out info))
193
+ throw new Win32Exception(Marshal.GetLastWin32Error());
194
+ return info.VolumeSerialNumber.ToString("X8") + ":" +
195
+ info.FileIndexHigh.ToString("X8") + info.FileIndexLow.ToString("X8");
196
+ }
197
+
198
+ public static string FinalDirectoryPath(string path) {
199
+ using (SafeFileHandle handle = OpenDirectory(path)) {
200
+ return FinalPath(handle);
201
+ }
202
+ }
203
+
204
+ public static SafeFileHandle OpenDirectory(string path) {
205
+ SafeFileHandle handle = CreateFileW(
206
+ path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
207
+ IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero);
208
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
209
+ return handle;
210
+ }
211
+ }
212
+ '@
213
+ }
214
+
215
+ $script:SecurityRootHandle = $null
216
+ $script:SecurityRootFinalPath = $null
217
+ if ($script:IsWindowsHost) {
218
+ $securityRootPath = (Get-Location).Path
219
+ if ($env:PWF_PLAN_ROOT) {
220
+ $pin = $env:PWF_PLAN_ROOT
221
+ $isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
222
+ if (-not [IO.Path]::IsPathFullyQualified($pin) -or $isUnc) {
223
+ throw "[plan-attest] PWF_PLAN_ROOT must be an absolute local path."
224
+ }
225
+ $securityRootPath = $pin
226
+ }
227
+ $script:SecurityRootHandle = [PwfAttestationNative]::OpenDirectory($securityRootPath)
228
+ $script:SecurityRootFinalPath = [PwfAttestationNative]::FinalPath($script:SecurityRootHandle).TrimEnd('\', '/')
229
+ }
230
+
231
+ function Test-FinalPathWithinSecurityRoot {
232
+ param([string] $FinalPath)
233
+ $candidate = $FinalPath.TrimEnd('\', '/')
234
+ if ([string]::Equals($candidate, $script:SecurityRootFinalPath, [StringComparison]::OrdinalIgnoreCase)) {
235
+ return $true
236
+ }
237
+ return $candidate.StartsWith(
238
+ $script:SecurityRootFinalPath + [IO.Path]::DirectorySeparatorChar,
239
+ [StringComparison]::OrdinalIgnoreCase
240
+ )
241
+ }
36
242
 
37
- if ($env:PLAN_ID) {
38
- $candidate = Join-Path $planRoot $env:PLAN_ID
39
- $planFile = Join-Path $candidate "task_plan.md"
40
- if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
243
+ function Open-TrustedDirectory {
244
+ param([string] $ExpectedDirectory)
245
+ $handle = [PwfAttestationNative]::OpenDirectory($ExpectedDirectory)
246
+ try {
247
+ $finalPath = [PwfAttestationNative]::FinalPath($handle).TrimEnd('\', '/')
248
+ if (-not (Test-FinalPathWithinSecurityRoot $finalPath)) {
249
+ throw "Refusing a plan directory outside the frozen project root."
250
+ }
251
+ return [PSCustomObject]@{
252
+ Handle = $handle
253
+ FinalPath = $finalPath
254
+ Identity = [PwfAttestationNative]::FileIdentity($handle)
255
+ }
256
+ } catch {
257
+ $handle.Dispose()
258
+ throw
41
259
  }
260
+ }
42
261
 
43
- $activePointer = Join-Path $planRoot ".active_plan"
44
- if (Test-Path -LiteralPath $activePointer) {
45
- $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
46
- if ($planId) {
47
- $candidate = Join-Path $planRoot $planId
48
- $planFile = Join-Path $candidate "task_plan.md"
49
- if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
262
+ function Assert-HandleParent {
263
+ param(
264
+ [Microsoft.Win32.SafeHandles.SafeFileHandle] $Handle,
265
+ [string] $ExpectedDirectoryFinal,
266
+ [string] $ExpectedDirectoryIdentity
267
+ )
268
+ if (-not $script:IsWindowsHost) { return }
269
+ $openedPath = [PwfAttestationNative]::FinalPath($Handle)
270
+ $openedParent = (Split-Path -Parent $openedPath).TrimEnd('\', '/')
271
+ if (-not [string]::Equals($openedParent, $ExpectedDirectoryFinal, [StringComparison]::OrdinalIgnoreCase)) {
272
+ throw "Refusing file outside the expected plan directory."
273
+ }
274
+ $parentHandle = [PwfAttestationNative]::OpenDirectory($openedParent)
275
+ try {
276
+ $openedIdentity = [PwfAttestationNative]::FileIdentity($parentHandle)
277
+ if (-not [string]::Equals($openedIdentity, $ExpectedDirectoryIdentity, [StringComparison]::Ordinal)) {
278
+ throw "Refusing a file whose parent directory identity changed."
50
279
  }
280
+ } finally {
281
+ $parentHandle.Dispose()
51
282
  }
283
+ }
52
284
 
53
- if (Test-Path -LiteralPath $planRoot) {
54
- $newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
55
- Where-Object { -not $_.Name.StartsWith(".") } |
56
- Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
57
- Sort-Object LastWriteTime -Descending |
58
- Select-Object -First 1
59
- if ($newest) {
60
- return (Resolve-Path -LiteralPath (Join-Path $newest.FullName "task_plan.md")).Path
285
+ function Open-SafeReadStream {
286
+ param([string] $Path, [string] $ExpectedDirectory, [switch] $SingleLink)
287
+ if (-not $script:IsWindowsHost) {
288
+ throw "[plan-attest] Safe no-follow descriptor operations are unavailable in this PowerShell script on Unix. Use scripts/attest-plan.sh instead."
289
+ }
290
+ $directory = Open-TrustedDirectory -ExpectedDirectory $ExpectedDirectory
291
+ try {
292
+ $handle = [PwfAttestationNative]::OpenRead($Path, [bool]$SingleLink)
293
+ try {
294
+ Assert-HandleParent -Handle $handle -ExpectedDirectoryFinal $directory.FinalPath -ExpectedDirectoryIdentity $directory.Identity
295
+ return New-Object System.IO.FileStream($handle, [IO.FileAccess]::Read)
296
+ } catch {
297
+ $handle.Dispose()
298
+ throw
61
299
  }
300
+ } finally {
301
+ $directory.Handle.Dispose()
62
302
  }
303
+ }
63
304
 
64
- $legacy = Join-Path (Get-Location) "task_plan.md"
65
- if (Test-Path -LiteralPath $legacy) {
66
- return (Resolve-Path -LiteralPath $legacy).Path
305
+ function Read-SafeText {
306
+ param([string] $Path, [string] $ExpectedDirectory, [int64] $MaxBytes)
307
+ $stream = Open-SafeReadStream -Path $Path -ExpectedDirectory $ExpectedDirectory -SingleLink
308
+ try {
309
+ if ($stream.Length -gt $MaxBytes) { throw "Refusing oversized metadata file."
310
+ }
311
+ $buffer = New-Object byte[] ([int]$stream.Length)
312
+ $offset = 0
313
+ while ($offset -lt $buffer.Length) {
314
+ $read = $stream.Read($buffer, $offset, $buffer.Length - $offset)
315
+ if ($read -le 0) { break }
316
+ $offset += $read
317
+ }
318
+ return [Text.Encoding]::UTF8.GetString($buffer, 0, $offset)
319
+ } finally {
320
+ $stream.Dispose()
67
321
  }
322
+ }
68
323
 
69
- return $null
324
+ function Write-SafeAscii {
325
+ param([string] $Path, [string] $ExpectedDirectory, [string] $Value)
326
+ $bytes = [Text.Encoding]::ASCII.GetBytes($Value)
327
+ if (-not $script:IsWindowsHost) {
328
+ throw "[plan-attest] Safe no-follow descriptor operations are unavailable in this PowerShell script on Unix. Use scripts/attest-plan.sh instead."
329
+ }
330
+ $directory = Open-TrustedDirectory -ExpectedDirectory $ExpectedDirectory
331
+ try {
332
+ $handle = [PwfAttestationNative]::OpenAttestationWrite($Path)
333
+ try {
334
+ Assert-HandleParent -Handle $handle -ExpectedDirectoryFinal $directory.FinalPath -ExpectedDirectoryIdentity $directory.Identity
335
+ $stream = New-Object System.IO.FileStream($handle, [IO.FileAccess]::ReadWrite)
336
+ $handle = $null
337
+ try {
338
+ $stream.SetLength(0)
339
+ $stream.Write($bytes, 0, $bytes.Length)
340
+ $stream.Flush($true)
341
+ $stream.Position = 0
342
+ $verify = New-Object byte[] $bytes.Length
343
+ $read = $stream.Read($verify, 0, $verify.Length)
344
+ return [Text.Encoding]::ASCII.GetString($verify, 0, $read)
345
+ } finally {
346
+ $stream.Dispose()
347
+ }
348
+ } finally {
349
+ if ($handle) { $handle.Dispose() }
350
+ }
351
+ } finally {
352
+ $directory.Handle.Dispose()
353
+ }
354
+ }
355
+
356
+ function Remove-SafeFile {
357
+ param([string] $Path, [string] $ExpectedDirectory)
358
+ if (-not $script:IsWindowsHost) {
359
+ throw "[plan-attest] Safe no-follow descriptor operations are unavailable in this PowerShell script on Unix. Use scripts/attest-plan.sh instead."
360
+ }
361
+ $directory = Open-TrustedDirectory -ExpectedDirectory $ExpectedDirectory
362
+ try {
363
+ $handle = [PwfAttestationNative]::OpenDelete($Path)
364
+ try {
365
+ Assert-HandleParent -Handle $handle -ExpectedDirectoryFinal $directory.FinalPath -ExpectedDirectoryIdentity $directory.Identity
366
+ [PwfAttestationNative]::DeleteOpened($handle)
367
+ } finally {
368
+ $handle.Dispose()
369
+ }
370
+ } finally {
371
+ $directory.Handle.Dispose()
372
+ }
373
+ }
374
+
375
+ function Resolve-ContainedPlanFile {
376
+ param(
377
+ [string] $Candidate,
378
+ [string] $ExpectedDirectory
379
+ )
380
+ if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) { return $null }
381
+ try {
382
+ $stream = Open-SafeReadStream -Path $Candidate -ExpectedDirectory $ExpectedDirectory
383
+ $stream.Dispose()
384
+ return (Get-Item -LiteralPath $Candidate -Force -ErrorAction Stop).FullName
385
+ } catch { return $null }
386
+ }
387
+
388
+ function Test-SlugPlanDirectory {
389
+ param([string] $Directory)
390
+ try {
391
+ $finalDirectory = [PwfAttestationNative]::FinalDirectoryPath($Directory)
392
+ } catch {
393
+ return $false
394
+ }
395
+ $planningDirectory = Split-Path -Parent $finalDirectory
396
+ $planId = Split-Path -Leaf $finalDirectory
397
+ return (
398
+ (Split-Path -Leaf $planningDirectory) -eq ".planning" -and
399
+ $planId -match '^[A-Za-z0-9_][A-Za-z0-9._-]*$'
400
+ )
401
+ }
402
+
403
+ function Resolve-PlanFile {
404
+ $resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
405
+ if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
406
+ $resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
407
+ if ($resolvedDir) {
408
+ $planFile = Join-Path $resolvedDir "task_plan.md"
409
+ return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
410
+ }
411
+
412
+ # An explicit pin or a scoped selector that failed validation must never
413
+ # fall through and attest an unrelated legacy-root plan.
414
+ if ($env:PWF_PLAN_ROOT -or $env:PLAN_ID) { return $null }
415
+ $activePointer = Join-Path (Join-Path (Get-Location) ".planning") ".active_plan"
416
+ $activePointerItem = Get-Item -LiteralPath $activePointer -Force -ErrorAction SilentlyContinue
417
+ if ($activePointerItem) { return $null }
418
+
419
+ $currentDirectory = (Get-Location).Path
420
+ if (Test-SlugPlanDirectory $currentDirectory) {
421
+ $slugPlan = Join-Path $currentDirectory "task_plan.md"
422
+ return (Resolve-ContainedPlanFile -Candidate $slugPlan -ExpectedDirectory $currentDirectory)
423
+ }
424
+
425
+ $legacy = Join-Path $currentDirectory "task_plan.md"
426
+ return (Resolve-ContainedPlanFile -Candidate $legacy -ExpectedDirectory $currentDirectory)
70
427
  }
71
428
 
72
429
  function Get-AttestationPath {
@@ -74,6 +431,9 @@ function Get-AttestationPath {
74
431
  $planDir = Split-Path -Parent $PlanFile
75
432
  $cwd = (Get-Location).Path
76
433
  if ($planDir -eq $cwd) {
434
+ if (Test-SlugPlanDirectory $cwd) {
435
+ return (Join-Path $cwd ".attestation")
436
+ }
77
437
  return (Join-Path $cwd ".plan-attestation")
78
438
  }
79
439
  return (Join-Path $planDir ".attestation")
@@ -86,18 +446,19 @@ if (-not $planFile) {
86
446
  }
87
447
 
88
448
  $attestationFile = Get-AttestationPath -PlanFile $planFile
449
+ $attestationDir = Split-Path -Parent $attestationFile
89
450
 
90
451
  if ($Show) {
91
- if (Test-Path -LiteralPath $attestationFile) {
452
+ if (Get-Item -LiteralPath $attestationFile -Force -ErrorAction SilentlyContinue) {
92
453
  Write-Output "Plan: $planFile"
93
454
  Write-Output "Attestation: $attestationFile"
94
- Write-Output ("SHA-256: " + (Get-Content -LiteralPath $attestationFile -Raw).Trim())
455
+ Write-Output ("SHA-256: " + (Read-SafeText -Path $attestationFile -ExpectedDirectory $attestationDir -MaxBytes 4096).Trim())
95
456
  # Nonce (security A1.4): surface the per-plan nonce if init-session
96
457
  # generated one next to the attestation. Informational only here; the
97
458
  # hooks consume it to build collision-proof BEGIN/END delimiters.
98
459
  $nonceFile = Join-Path (Split-Path -Parent $attestationFile) ".nonce"
99
- if (Test-Path -LiteralPath $nonceFile) {
100
- $nonceVal = (Get-Content -LiteralPath $nonceFile -Raw).Trim()
460
+ if (Get-Item -LiteralPath $nonceFile -Force -ErrorAction SilentlyContinue) {
461
+ $nonceVal = (Read-SafeText -Path $nonceFile -ExpectedDirectory $attestationDir -MaxBytes 4096).Trim()
101
462
  if ($nonceVal) { Write-Output "Nonce: $nonceVal" }
102
463
  }
103
464
  } else {
@@ -108,8 +469,8 @@ if ($Show) {
108
469
  }
109
470
 
110
471
  if ($Clear) {
111
- if (Test-Path -LiteralPath $attestationFile) {
112
- Remove-Item -LiteralPath $attestationFile -Force
472
+ if (Get-Item -LiteralPath $attestationFile -Force -ErrorAction SilentlyContinue) {
473
+ Remove-SafeFile -Path $attestationFile -ExpectedDirectory $attestationDir
113
474
  Write-Output "[plan-attest] Cleared attestation for $planFile."
114
475
  } else {
115
476
  Write-Output "[plan-attest] No attestation to clear."
@@ -117,13 +478,23 @@ if ($Clear) {
117
478
  exit 0
118
479
  }
119
480
 
120
- $hashVal = (Get-FileHash -LiteralPath $planFile -Algorithm SHA256).Hash.ToLowerInvariant()
121
- Set-Content -LiteralPath $attestationFile -Value $hashVal -NoNewline -Encoding ascii
481
+ $planStream = Open-SafeReadStream -Path $planFile -ExpectedDirectory (Split-Path -Parent $planFile)
482
+ try {
483
+ $sha256 = [Security.Cryptography.SHA256]::Create()
484
+ try {
485
+ $hashBytes = $sha256.ComputeHash($planStream)
486
+ } finally {
487
+ $sha256.Dispose()
488
+ }
489
+ } finally {
490
+ $planStream.Dispose()
491
+ }
492
+ $hashVal = ([BitConverter]::ToString($hashBytes)).Replace("-", "").ToLowerInvariant()
493
+ $storedHash = Write-SafeAscii -Path $attestationFile -ExpectedDirectory $attestationDir -Value $hashVal
122
494
 
123
495
  # Integrity verification (security A2.1): confirm the on-disk attestation
124
496
  # matches the intended hash before reporting success. A silent write failure
125
497
  # (permissions, full disk) must not leave a stale attestation and exit clean.
126
- $storedHash = (Get-Content -LiteralPath $attestationFile -Raw -ErrorAction SilentlyContinue)
127
498
  if ($null -ne $storedHash) { $storedHash = $storedHash.Trim() }
128
499
  if ($storedHash -ne $hashVal) {
129
500
  Write-Error "[plan-attest] Attestation write verification FAILED for $attestationFile. Expected $hashVal, found $storedHash. The plan is NOT attested."