planning-with-files 3.10.2 → 3.12.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,175 +1,237 @@
1
- #requires -Version 5.0
2
- <#
3
- .SYNOPSIS
4
- Set the status of one phase in task_plan.md (PowerShell mirror, v3).
5
-
6
- .DESCRIPTION
7
- The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
8
- orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
9
- a read-modify-write under an exclusive lock on the <plan-dir>\.write_lock
10
- sentinel, with an atomic temp-file + move swap so a torn write can never
11
- leave a half-rewritten plan on disk (architecture C4).
12
-
13
- Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
14
- phase boundaries (see attest-plan.ps1).
15
-
16
- Plan-dir resolution matches resolve-plan-dir.ps1:
17
- 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
18
- 2. .\.planning\.active_plan
19
- 3. Newest .\.planning\<dir>\ by LastWriteTime
20
- 4. Legacy: project root .\task_plan.md
21
-
22
- Exits 1 with a message if the phase does not exist or the status is invalid.
23
-
24
- .PARAMETER Phase
25
- Phase number (positive integer).
26
-
27
- .PARAMETER Status
28
- New status: pending, in_progress, or complete.
29
- #>
30
- [CmdletBinding()]
31
- param(
32
- [Parameter(Mandatory = $true, Position = 0)]
33
- [string] $Phase,
34
-
35
- [Parameter(Mandatory = $true, Position = 1)]
36
- [string] $Status
37
- )
38
-
39
- $ErrorActionPreference = "Stop"
40
-
41
- function Resolve-PlanFile {
42
- $planRoot = Join-Path (Get-Location) ".planning"
43
-
44
- if ($env:PLAN_ID) {
45
- $candidate = Join-Path $planRoot $env:PLAN_ID
46
- $planFile = Join-Path $candidate "task_plan.md"
47
- if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
48
- }
49
-
50
- $activePointer = Join-Path $planRoot ".active_plan"
51
- if (Test-Path -LiteralPath $activePointer) {
52
- $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
53
- if ($planId) {
54
- $candidate = Join-Path $planRoot $planId
55
- $planFile = Join-Path $candidate "task_plan.md"
56
- if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
57
- }
58
- }
59
-
60
- if (Test-Path -LiteralPath $planRoot -PathType Container) {
61
- $newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
62
- Where-Object { -not $_.Name.StartsWith(".") } |
63
- Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
64
- Sort-Object LastWriteTime -Descending |
65
- Select-Object -First 1
66
- if ($newest) {
67
- return (Resolve-Path -LiteralPath (Join-Path $newest.FullName "task_plan.md")).Path
68
- }
69
- }
70
-
71
- $legacy = Join-Path (Get-Location) "task_plan.md"
72
- if (Test-Path -LiteralPath $legacy) {
73
- return (Resolve-Path -LiteralPath $legacy).Path
74
- }
75
-
76
- return $null
77
- }
78
-
79
- # Validate phase number is a positive integer.
80
- if ($Phase -notmatch '^[0-9]+$') {
81
- Write-Error ("[phase-status] phase number must be a positive integer, got '" + $Phase + "'.")
82
- exit 1
83
- }
84
-
85
- # Validate status value against the allowlist.
86
- $validStatus = @("pending", "in_progress", "complete")
87
- if ($validStatus -notcontains $Status) {
88
- Write-Error ("[phase-status] invalid status '" + $Status + "' (allowed: pending, in_progress, complete).")
89
- exit 1
90
- }
91
-
92
- $planFile = Resolve-PlanFile
93
- if (-not $planFile) {
94
- Write-Error "[phase-status] No task_plan.md found. Create a plan first."
95
- exit 1
96
- }
97
-
98
- $planDir = Split-Path -Parent $planFile
99
- $lockFile = Join-Path $planDir ".write_lock"
100
-
101
- # Acquire an exclusive lock on the sentinel so concurrent writers serialize.
102
- $fs = $null
103
- $acquired = $false
104
- for ($i = 0; $i -lt 50 -and -not $acquired; $i++) {
105
- try {
106
- $fs = [System.IO.File]::Open($lockFile, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
107
- $acquired = $true
108
- } catch {
109
- Start-Sleep -Milliseconds 100
110
- }
111
- }
112
-
113
- $tmpFile = $planFile + ".tmp." + $PID
114
- $rc = 0
115
- try {
116
- $lines = Get-Content -LiteralPath $planFile
117
-
118
- # Confirm the phase heading exists.
119
- $headingRe = '^### Phase ' + $Phase + '([^0-9]|$)'
120
- if (-not ($lines | Where-Object { $_ -match $headingRe })) {
121
- Write-Error ("[phase-status] Phase " + $Phase + " not found in " + $planFile + ".")
122
- $rc = 1
123
- } else {
124
- $inBlock = $false
125
- $done = $false
126
- $out = New-Object System.Collections.Generic.List[string]
127
- foreach ($line in $lines) {
128
- $emit = $line
129
- if ($line -match '^### Phase ') {
130
- $rest = $line -replace '^### Phase ', ''
131
- $num = $rest -replace '[^0-9].*$', ''
132
- if (($num -eq $Phase) -and (-not $done)) {
133
- $inBlock = $true
134
- } else {
135
- $inBlock = $false
136
- }
137
- } elseif ($inBlock -and (-not $done) -and ($line -match '\*\*Status:\*\*')) {
138
- $prefix = $line -replace '\*\*Status:\*\*.*$', ''
139
- $emit = $prefix + '**Status:** ' + $Status
140
- $inBlock = $false
141
- $done = $true
142
- }
143
- $out.Add($emit)
144
- }
145
-
146
- if (-not $done) {
147
- Write-Error ("[phase-status] No **Status:** line found for Phase " + $Phase + ".")
148
- $rc = 1
149
- } else {
150
- # Atomic-enough swap: write temp, then move over the target.
151
- # Write BOM-less UTF-8 (platform-major): Set-Content -Encoding utf8 on
152
- # Windows PowerShell 5.1 prepends a UTF-8 BOM (EF BB BF). The temp file
153
- # then replaces task_plan.md, so every phase-status call from PS 5.1
154
- # changes the file's leading bytes. If the plan was created on Linux or
155
- # macOS (no BOM), the stored attestation SHA-256 no longer matches and
156
- # inject-plan.sh blocks all further injection as [PLAN TAMPERED]. A
157
- # UTF8Encoding constructed with $false emits no BOM on every PS version.
158
- $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
159
- [System.IO.File]::WriteAllLines($tmpFile, $out, $utf8NoBom)
160
- Move-Item -LiteralPath $tmpFile -Destination $planFile -Force
161
- }
162
- }
163
- } catch {
164
- Write-Error ("[phase-status] " + $_.Exception.Message)
165
- $rc = 1
166
- } finally {
167
- if ($fs) { $fs.Close(); $fs.Dispose() }
168
- if (Test-Path -LiteralPath $lockFile) { Remove-Item -LiteralPath $lockFile -Force -ErrorAction SilentlyContinue }
169
- if (Test-Path -LiteralPath $tmpFile) { Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue }
170
- }
171
-
172
- if ($rc -ne 0) { exit 1 }
173
-
174
- Write-Output ("[phase-status] Phase " + $Phase + " -> " + $Status + " in " + $planFile)
175
- exit 0
1
+ #requires -Version 5.0
2
+ <#
3
+ .SYNOPSIS
4
+ Set the status of one phase in task_plan.md (PowerShell mirror, v3).
5
+
6
+ .DESCRIPTION
7
+ The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
8
+ orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
9
+ a read-modify-write under the portable
10
+ <plan-dir>\.pwf-locks\phase-status.lock directory lock, with an atomic
11
+ temp-file + move swap so a torn write can never leave a half-rewritten plan
12
+ on disk (architecture C4).
13
+
14
+ Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
15
+ phase boundaries (see attest-plan.ps1).
16
+
17
+ Plan-dir resolution matches resolve-plan-dir.ps1:
18
+ 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
19
+ 2. .\.planning\.active_plan
20
+ 3. Newest .\.planning\<dir>\ by LastWriteTime
21
+ 4. Legacy: project root .\task_plan.md
22
+
23
+ Exits 1 with a message if the phase does not exist or the status is invalid.
24
+
25
+ .PARAMETER Phase
26
+ Phase number (positive integer).
27
+
28
+ .PARAMETER Status
29
+ New status: pending, in_progress, or complete.
30
+ #>
31
+ [CmdletBinding()]
32
+ param(
33
+ [Parameter(Mandatory = $true, Position = 0)]
34
+ [string] $Phase,
35
+
36
+ [Parameter(Mandatory = $true, Position = 1)]
37
+ [string] $Status
38
+ )
39
+
40
+ $ErrorActionPreference = "Stop"
41
+
42
+ function Resolve-PlanFile {
43
+ $planRoot = Join-Path (Get-Location) ".planning"
44
+
45
+ if ($env:PLAN_ID) {
46
+ $candidate = Join-Path $planRoot $env:PLAN_ID
47
+ $planFile = Join-Path $candidate "task_plan.md"
48
+ if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
49
+ }
50
+
51
+ $activePointer = Join-Path $planRoot ".active_plan"
52
+ if (Test-Path -LiteralPath $activePointer) {
53
+ $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
54
+ if ($planId) {
55
+ $candidate = Join-Path $planRoot $planId
56
+ $planFile = Join-Path $candidate "task_plan.md"
57
+ if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
58
+ }
59
+ }
60
+
61
+ if (Test-Path -LiteralPath $planRoot -PathType Container) {
62
+ $newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
63
+ Where-Object { -not $_.Name.StartsWith(".") } |
64
+ Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
65
+ Sort-Object LastWriteTime -Descending |
66
+ Select-Object -First 1
67
+ if ($newest) {
68
+ return (Resolve-Path -LiteralPath (Join-Path $newest.FullName "task_plan.md")).Path
69
+ }
70
+ }
71
+
72
+ $legacy = Join-Path (Get-Location) "task_plan.md"
73
+ if (Test-Path -LiteralPath $legacy) {
74
+ return (Resolve-Path -LiteralPath $legacy).Path
75
+ }
76
+
77
+ return $null
78
+ }
79
+
80
+ function Enter-PwfDirectoryLock {
81
+ param(
82
+ [string] $LockRoot,
83
+ [string] $LockDir
84
+ )
85
+
86
+ try {
87
+ [void][System.IO.Directory]::CreateDirectory($LockRoot)
88
+ } catch {
89
+ Write-Error ("[phase-status] Cannot create lock root " + $LockRoot + ": " + $_.Exception.Message)
90
+ return $null
91
+ }
92
+
93
+ $token = "phase-status-" + $PID + "-" + [Guid]::NewGuid().ToString("N")
94
+ $ownerFile = Join-Path $LockDir ".owner"
95
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
96
+
97
+ $wait = [Diagnostics.Stopwatch]::StartNew()
98
+ while ($wait.Elapsed.TotalSeconds -lt 5) {
99
+ $createdByUs = $false
100
+ try {
101
+ New-Item -Path $LockDir -ItemType Directory -ErrorAction Stop | Out-Null
102
+ $createdByUs = $true
103
+ [System.IO.File]::WriteAllText($ownerFile, $token + "`n", $utf8NoBom)
104
+ return [PSCustomObject]@{
105
+ Directory = $LockDir
106
+ OwnerFile = $ownerFile
107
+ Token = $token
108
+ }
109
+ } catch {
110
+ if ($createdByUs) {
111
+ try {
112
+ if ([System.IO.File]::Exists($ownerFile)) {
113
+ $ownerValue = [System.IO.File]::ReadAllText($ownerFile).Trim()
114
+ if ([string]::Equals($ownerValue, $token, [StringComparison]::Ordinal)) {
115
+ [System.IO.File]::Delete($ownerFile)
116
+ }
117
+ }
118
+ [System.IO.Directory]::Delete($LockDir, $false)
119
+ } catch {
120
+ # Leave any directory we cannot prove is still ours intact.
121
+ }
122
+ }
123
+ Start-Sleep -Milliseconds 100
124
+ }
125
+ }
126
+
127
+ return $null
128
+ }
129
+
130
+ function Exit-PwfDirectoryLock {
131
+ param($Lock)
132
+ if (-not $Lock) { return }
133
+ try {
134
+ if (-not [System.IO.File]::Exists($Lock.OwnerFile)) { return }
135
+ $ownerValue = [System.IO.File]::ReadAllText($Lock.OwnerFile).Trim()
136
+ if (-not [string]::Equals($ownerValue, $Lock.Token, [StringComparison]::Ordinal)) { return }
137
+ [System.IO.File]::Delete($Lock.OwnerFile)
138
+ [System.IO.Directory]::Delete($Lock.Directory, $false)
139
+ } catch {
140
+ # Cleanup is best-effort and never removes a lock with another owner.
141
+ }
142
+ }
143
+
144
+ # Validate phase number is a positive integer.
145
+ if ($Phase -notmatch '^[0-9]+$') {
146
+ Write-Error ("[phase-status] phase number must be a positive integer, got '" + $Phase + "'.")
147
+ exit 1
148
+ }
149
+
150
+ # Validate status value against the allowlist.
151
+ $validStatus = @("pending", "in_progress", "complete")
152
+ if ($validStatus -notcontains $Status) {
153
+ Write-Error ("[phase-status] invalid status '" + $Status + "' (allowed: pending, in_progress, complete).")
154
+ exit 1
155
+ }
156
+
157
+ $planFile = Resolve-PlanFile
158
+ if (-not $planFile) {
159
+ Write-Error "[phase-status] No task_plan.md found. Create a plan first."
160
+ exit 1
161
+ }
162
+
163
+ $planDir = Split-Path -Parent $planFile
164
+ $lockRoot = Join-Path $planDir ".pwf-locks"
165
+ $lockDir = Join-Path $lockRoot "phase-status.lock"
166
+
167
+ # Atomic directory creation is the common lock primitive used by both the sh
168
+ # and PowerShell implementations. Failure to acquire within about five seconds
169
+ # is fail-closed: no plan read/rewrite is attempted.
170
+ $lock = Enter-PwfDirectoryLock -LockRoot $lockRoot -LockDir $lockDir
171
+ if (-not $lock) {
172
+ Write-Error ("[phase-status] Timed out waiting for lock " + $lockDir + ". No plan changes were made.")
173
+ exit 75
174
+ }
175
+
176
+ $tmpFile = $planFile + ".tmp." + $PID
177
+ $rc = 0
178
+ try {
179
+ $lines = Get-Content -LiteralPath $planFile
180
+
181
+ # Confirm the phase heading exists.
182
+ $headingRe = '^### Phase ' + $Phase + '([^0-9]|$)'
183
+ if (-not ($lines | Where-Object { $_ -match $headingRe })) {
184
+ Write-Error ("[phase-status] Phase " + $Phase + " not found in " + $planFile + ".")
185
+ $rc = 1
186
+ } else {
187
+ $inBlock = $false
188
+ $done = $false
189
+ $out = New-Object System.Collections.Generic.List[string]
190
+ foreach ($line in $lines) {
191
+ $emit = $line
192
+ if ($line -match '^### Phase ') {
193
+ $rest = $line -replace '^### Phase ', ''
194
+ $num = $rest -replace '[^0-9].*$', ''
195
+ if (($num -eq $Phase) -and (-not $done)) {
196
+ $inBlock = $true
197
+ } else {
198
+ $inBlock = $false
199
+ }
200
+ } elseif ($inBlock -and (-not $done) -and ($line -match '\*\*Status:\*\*')) {
201
+ $prefix = $line -replace '\*\*Status:\*\*.*$', ''
202
+ $emit = $prefix + '**Status:** ' + $Status
203
+ $inBlock = $false
204
+ $done = $true
205
+ }
206
+ $out.Add($emit)
207
+ }
208
+
209
+ if (-not $done) {
210
+ Write-Error ("[phase-status] No **Status:** line found for Phase " + $Phase + ".")
211
+ $rc = 1
212
+ } else {
213
+ # Atomic-enough swap: write temp, then move over the target.
214
+ # Write BOM-less UTF-8 (platform-major): Set-Content -Encoding utf8 on
215
+ # Windows PowerShell 5.1 prepends a UTF-8 BOM (EF BB BF). The temp file
216
+ # then replaces task_plan.md, so every phase-status call from PS 5.1
217
+ # changes the file's leading bytes. If the plan was created on Linux or
218
+ # macOS (no BOM), the stored attestation SHA-256 no longer matches and
219
+ # inject-plan.sh blocks all further injection as [PLAN TAMPERED]. A
220
+ # UTF8Encoding constructed with $false emits no BOM on every PS version.
221
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
222
+ [System.IO.File]::WriteAllLines($tmpFile, $out, $utf8NoBom)
223
+ Move-Item -LiteralPath $tmpFile -Destination $planFile -Force
224
+ }
225
+ }
226
+ } catch {
227
+ Write-Error ("[phase-status] " + $_.Exception.Message)
228
+ $rc = 1
229
+ } finally {
230
+ Exit-PwfDirectoryLock -Lock $lock
231
+ if (Test-Path -LiteralPath $tmpFile) { Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue }
232
+ }
233
+
234
+ if ($rc -ne 0) { exit 1 }
235
+
236
+ Write-Output ("[phase-status] Phase " + $Phase + " -> " + $Status + " in " + $planFile)
237
+ exit 0