planning-with-files 3.10.2 → 3.11.2

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,128 +1,128 @@
1
- #requires -Version 5.0
2
- <#
3
- .SYNOPSIS
4
- Emit a fixed-shape, cache-stable run-ledger summary (PowerShell mirror, v3).
5
-
6
- .DESCRIPTION
7
- Replaces raw progress.md tail injection in autonomous mode. Output is
8
- synthesized from the machine ledger and task_plan.md status counts only:
9
- NO free text from disk reaches model context, and NO timestamps, so the
10
- injected block is KV-cache stable by construction (architecture C3).
11
-
12
- Plan-dir resolution matches resolve-plan-dir.ps1:
13
- 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
14
- 2. .\.planning\.active_plan
15
- 3. Newest .\.planning\<dir>\ by LastWriteTime
16
- 4. Legacy: project root
17
-
18
- Output block (stable shape):
19
- === RUN LEDGER ===
20
- entries: <N>
21
- phases: <complete>/<total> complete
22
- in_progress: <phase heading or none>
23
- agent <name>: <last event type>
24
- ==================
25
- #>
26
- [CmdletBinding()]
27
- param()
28
-
29
- $ErrorActionPreference = "Stop"
30
-
31
- function Resolve-PlanDir {
32
- $planRoot = Join-Path (Get-Location) ".planning"
33
-
34
- if ($env:PLAN_ID) {
35
- $candidate = Join-Path $planRoot $env:PLAN_ID
36
- if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
37
- }
38
-
39
- $activePointer = Join-Path $planRoot ".active_plan"
40
- if (Test-Path -LiteralPath $activePointer) {
41
- $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
42
- if ($planId) {
43
- $candidate = Join-Path $planRoot $planId
44
- if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
45
- }
46
- }
47
-
48
- if (Test-Path -LiteralPath $planRoot -PathType Container) {
49
- $newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
50
- Where-Object { -not $_.Name.StartsWith(".") } |
51
- Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
52
- Sort-Object LastWriteTime -Descending |
53
- Select-Object -First 1
54
- if ($newest) { return $newest.FullName }
55
- }
56
-
57
- return (Get-Location).Path
58
- }
59
-
60
- $planDir = Resolve-PlanDir
61
- $planFile = Join-Path $planDir "task_plan.md"
62
-
63
- # --- Phase counts: same patterns as check-complete.ps1 ---
64
- $TOTAL = 0
65
- $COMPLETE = 0
66
- $IN_PROGRESS = 0
67
- $inProgressHeading = "none"
68
-
69
- if (Test-Path -LiteralPath $planFile) {
70
- $content = Get-Content -LiteralPath $planFile -Raw
71
- $TOTAL = ([regex]::Matches($content, "### Phase")).Count
72
- $COMPLETE = ([regex]::Matches($content, "\*\*Status:\*\* complete")).Count
73
- $IN_PROGRESS = ([regex]::Matches($content, "\*\*Status:\*\* in_progress")).Count
74
-
75
- if ($COMPLETE -eq 0 -and $IN_PROGRESS -eq 0) {
76
- $c2 = ([regex]::Matches($content, "\[complete\]")).Count
77
- $i2 = ([regex]::Matches($content, "\[in_progress\]")).Count
78
- if ($c2 -gt 0 -or $i2 -gt 0) {
79
- $COMPLETE = $c2
80
- $IN_PROGRESS = $i2
81
- }
82
- }
83
-
84
- # Heading of the first phase block whose status is in_progress.
85
- $heading = ""
86
- foreach ($line in (Get-Content -LiteralPath $planFile)) {
87
- if ($line -match "^### Phase") {
88
- $heading = $line
89
- } elseif ($line -match "\*\*Status:\*\* in_progress" -or $line -match "\[in_progress\]") {
90
- if ($heading) {
91
- $inProgressHeading = $heading
92
- break
93
- }
94
- }
95
- }
96
- }
97
-
98
- # --- Ledger stats ---
99
- $totalEntries = 0
100
- $ledgerFiles = Get-ChildItem -LiteralPath $planDir -Filter "ledger-*.jsonl" -File -ErrorAction SilentlyContinue
101
- foreach ($f in $ledgerFiles) {
102
- $lines = Get-Content -LiteralPath $f.FullName -ErrorAction SilentlyContinue
103
- foreach ($line in $lines) {
104
- if ($line -match '"tick"') { $totalEntries++ }
105
- }
106
- }
107
-
108
- Write-Output "=== RUN LEDGER ==="
109
- Write-Output ("entries: " + $totalEntries)
110
- Write-Output ("phases: " + $COMPLETE + "/" + $TOTAL + " complete")
111
- Write-Output ("in_progress: " + $inProgressHeading)
112
-
113
- foreach ($f in $ledgerFiles) {
114
- $agent = $f.Name -replace '^ledger-', '' -replace '\.jsonl$', ''
115
- # @(...) forces array semantics: a single-line file returns a string from
116
- # Get-Content and $lines[-1] would otherwise index the last character.
117
- $lines = @(Get-Content -LiteralPath $f.FullName -ErrorAction SilentlyContinue)
118
- $lastEvent = "none"
119
- if ($lines.Count -gt 0) {
120
- $lastLine = $lines[$lines.Count - 1]
121
- $m = [regex]::Match($lastLine, '"event"\s*:\s*"([A-Za-z_]+)"')
122
- if ($m.Success) { $lastEvent = $m.Groups[1].Value }
123
- }
124
- Write-Output ("agent " + $agent + ": " + $lastEvent)
125
- }
126
-
127
- Write-Output "=================="
128
- exit 0
1
+ #requires -Version 5.0
2
+ <#
3
+ .SYNOPSIS
4
+ Emit a fixed-shape, cache-stable run-ledger summary (PowerShell mirror, v3).
5
+
6
+ .DESCRIPTION
7
+ Replaces raw progress.md tail injection in autonomous mode. Output is
8
+ synthesized from the machine ledger and task_plan.md status counts only:
9
+ NO free text from disk reaches model context, and NO timestamps, so the
10
+ injected block is KV-cache stable by construction (architecture C3).
11
+
12
+ Plan-dir resolution matches resolve-plan-dir.ps1:
13
+ 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
14
+ 2. .\.planning\.active_plan
15
+ 3. Newest .\.planning\<dir>\ by LastWriteTime
16
+ 4. Legacy: project root
17
+
18
+ Output block (stable shape):
19
+ === RUN LEDGER ===
20
+ entries: <N>
21
+ phases: <complete>/<total> complete
22
+ in_progress: <phase heading or none>
23
+ agent <name>: <last event type>
24
+ ==================
25
+ #>
26
+ [CmdletBinding()]
27
+ param()
28
+
29
+ $ErrorActionPreference = "Stop"
30
+
31
+ function Resolve-PlanDir {
32
+ $planRoot = Join-Path (Get-Location) ".planning"
33
+
34
+ if ($env:PLAN_ID) {
35
+ $candidate = Join-Path $planRoot $env:PLAN_ID
36
+ if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
37
+ }
38
+
39
+ $activePointer = Join-Path $planRoot ".active_plan"
40
+ if (Test-Path -LiteralPath $activePointer) {
41
+ $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
42
+ if ($planId) {
43
+ $candidate = Join-Path $planRoot $planId
44
+ if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
45
+ }
46
+ }
47
+
48
+ if (Test-Path -LiteralPath $planRoot -PathType Container) {
49
+ $newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
50
+ Where-Object { -not $_.Name.StartsWith(".") } |
51
+ Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
52
+ Sort-Object LastWriteTime -Descending |
53
+ Select-Object -First 1
54
+ if ($newest) { return $newest.FullName }
55
+ }
56
+
57
+ return (Get-Location).Path
58
+ }
59
+
60
+ $planDir = Resolve-PlanDir
61
+ $planFile = Join-Path $planDir "task_plan.md"
62
+
63
+ # --- Phase counts: same patterns as check-complete.ps1 ---
64
+ $TOTAL = 0
65
+ $COMPLETE = 0
66
+ $IN_PROGRESS = 0
67
+ $inProgressHeading = "none"
68
+
69
+ if (Test-Path -LiteralPath $planFile) {
70
+ $content = Get-Content -LiteralPath $planFile -Raw
71
+ $TOTAL = ([regex]::Matches($content, "### Phase")).Count
72
+ $COMPLETE = ([regex]::Matches($content, "\*\*Status:\*\* complete")).Count
73
+ $IN_PROGRESS = ([regex]::Matches($content, "\*\*Status:\*\* in_progress")).Count
74
+
75
+ if ($COMPLETE -eq 0 -and $IN_PROGRESS -eq 0) {
76
+ $c2 = ([regex]::Matches($content, "\[complete\]")).Count
77
+ $i2 = ([regex]::Matches($content, "\[in_progress\]")).Count
78
+ if ($c2 -gt 0 -or $i2 -gt 0) {
79
+ $COMPLETE = $c2
80
+ $IN_PROGRESS = $i2
81
+ }
82
+ }
83
+
84
+ # Heading of the first phase block whose status is in_progress.
85
+ $heading = ""
86
+ foreach ($line in (Get-Content -LiteralPath $planFile)) {
87
+ if ($line -match "^### Phase") {
88
+ $heading = $line
89
+ } elseif ($line -match "\*\*Status:\*\* in_progress" -or $line -match "\[in_progress\]") {
90
+ if ($heading) {
91
+ $inProgressHeading = $heading
92
+ break
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ # --- Ledger stats ---
99
+ $totalEntries = 0
100
+ $ledgerFiles = Get-ChildItem -LiteralPath $planDir -Filter "ledger-*.jsonl" -File -ErrorAction SilentlyContinue
101
+ foreach ($f in $ledgerFiles) {
102
+ $lines = Get-Content -LiteralPath $f.FullName -ErrorAction SilentlyContinue
103
+ foreach ($line in $lines) {
104
+ if ($line -match '"tick"') { $totalEntries++ }
105
+ }
106
+ }
107
+
108
+ Write-Output "=== RUN LEDGER ==="
109
+ Write-Output ("entries: " + $totalEntries)
110
+ Write-Output ("phases: " + $COMPLETE + "/" + $TOTAL + " complete")
111
+ Write-Output ("in_progress: " + $inProgressHeading)
112
+
113
+ foreach ($f in $ledgerFiles) {
114
+ $agent = $f.Name -replace '^ledger-', '' -replace '\.jsonl$', ''
115
+ # @(...) forces array semantics: a single-line file returns a string from
116
+ # Get-Content and $lines[-1] would otherwise index the last character.
117
+ $lines = @(Get-Content -LiteralPath $f.FullName -ErrorAction SilentlyContinue)
118
+ $lastEvent = "none"
119
+ if ($lines.Count -gt 0) {
120
+ $lastLine = $lines[$lines.Count - 1]
121
+ $m = [regex]::Match($lastLine, '"event"\s*:\s*"([A-Za-z_]+)"')
122
+ if ($m.Success) { $lastEvent = $m.Groups[1].Value }
123
+ }
124
+ Write-Output ("agent " + $agent + ": " + $lastEvent)
125
+ }
126
+
127
+ Write-Output "=================="
128
+ exit 0
@@ -1,175 +1,175 @@
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 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