Files
2026-07-13 12:40:00 +08:00

176 lines
6.4 KiB
PowerShell

#requires -Version 5.0
<#
.SYNOPSIS
Set the status of one phase in task_plan.md (PowerShell mirror, v3).
.DESCRIPTION
The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
a read-modify-write under an exclusive lock on the <plan-dir>\.write_lock
sentinel, with an atomic temp-file + move swap so a torn write can never
leave a half-rewritten plan on disk (architecture C4).
Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
phase boundaries (see attest-plan.ps1).
Plan-dir resolution matches resolve-plan-dir.ps1:
1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
2. .\.planning\.active_plan
3. Newest .\.planning\<dir>\ by LastWriteTime
4. Legacy: project root .\task_plan.md
Exits 1 with a message if the phase does not exist or the status is invalid.
.PARAMETER Phase
Phase number (positive integer).
.PARAMETER Status
New status: pending, in_progress, or complete.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[string] $Phase,
[Parameter(Mandatory = $true, Position = 1)]
[string] $Status
)
$ErrorActionPreference = "Stop"
function Resolve-PlanFile {
$planRoot = Join-Path (Get-Location) ".planning"
if ($env:PLAN_ID) {
$candidate = Join-Path $planRoot $env:PLAN_ID
$planFile = Join-Path $candidate "task_plan.md"
if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
}
$activePointer = Join-Path $planRoot ".active_plan"
if (Test-Path -LiteralPath $activePointer) {
$planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
if ($planId) {
$candidate = Join-Path $planRoot $planId
$planFile = Join-Path $candidate "task_plan.md"
if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
}
}
if (Test-Path -LiteralPath $planRoot -PathType Container) {
$newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { -not $_.Name.StartsWith(".") } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($newest) {
return (Resolve-Path -LiteralPath (Join-Path $newest.FullName "task_plan.md")).Path
}
}
$legacy = Join-Path (Get-Location) "task_plan.md"
if (Test-Path -LiteralPath $legacy) {
return (Resolve-Path -LiteralPath $legacy).Path
}
return $null
}
# Validate phase number is a positive integer.
if ($Phase -notmatch '^[0-9]+$') {
Write-Error ("[phase-status] phase number must be a positive integer, got '" + $Phase + "'.")
exit 1
}
# Validate status value against the allowlist.
$validStatus = @("pending", "in_progress", "complete")
if ($validStatus -notcontains $Status) {
Write-Error ("[phase-status] invalid status '" + $Status + "' (allowed: pending, in_progress, complete).")
exit 1
}
$planFile = Resolve-PlanFile
if (-not $planFile) {
Write-Error "[phase-status] No task_plan.md found. Create a plan first."
exit 1
}
$planDir = Split-Path -Parent $planFile
$lockFile = Join-Path $planDir ".write_lock"
# Acquire an exclusive lock on the sentinel so concurrent writers serialize.
$fs = $null
$acquired = $false
for ($i = 0; $i -lt 50 -and -not $acquired; $i++) {
try {
$fs = [System.IO.File]::Open($lockFile, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$acquired = $true
} catch {
Start-Sleep -Milliseconds 100
}
}
$tmpFile = $planFile + ".tmp." + $PID
$rc = 0
try {
$lines = Get-Content -LiteralPath $planFile
# Confirm the phase heading exists.
$headingRe = '^### Phase ' + $Phase + '([^0-9]|$)'
if (-not ($lines | Where-Object { $_ -match $headingRe })) {
Write-Error ("[phase-status] Phase " + $Phase + " not found in " + $planFile + ".")
$rc = 1
} else {
$inBlock = $false
$done = $false
$out = New-Object System.Collections.Generic.List[string]
foreach ($line in $lines) {
$emit = $line
if ($line -match '^### Phase ') {
$rest = $line -replace '^### Phase ', ''
$num = $rest -replace '[^0-9].*$', ''
if (($num -eq $Phase) -and (-not $done)) {
$inBlock = $true
} else {
$inBlock = $false
}
} elseif ($inBlock -and (-not $done) -and ($line -match '\*\*Status:\*\*')) {
$prefix = $line -replace '\*\*Status:\*\*.*$', ''
$emit = $prefix + '**Status:** ' + $Status
$inBlock = $false
$done = $true
}
$out.Add($emit)
}
if (-not $done) {
Write-Error ("[phase-status] No **Status:** line found for Phase " + $Phase + ".")
$rc = 1
} else {
# Atomic-enough swap: write temp, then move over the target.
# Write BOM-less UTF-8 (platform-major): Set-Content -Encoding utf8 on
# Windows PowerShell 5.1 prepends a UTF-8 BOM (EF BB BF). The temp file
# then replaces task_plan.md, so every phase-status call from PS 5.1
# changes the file's leading bytes. If the plan was created on Linux or
# macOS (no BOM), the stored attestation SHA-256 no longer matches and
# inject-plan.sh blocks all further injection as [PLAN TAMPERED]. A
# UTF8Encoding constructed with $false emits no BOM on every PS version.
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllLines($tmpFile, $out, $utf8NoBom)
Move-Item -LiteralPath $tmpFile -Destination $planFile -Force
}
}
} catch {
Write-Error ("[phase-status] " + $_.Exception.Message)
$rc = 1
} finally {
if ($fs) { $fs.Close(); $fs.Dispose() }
if (Test-Path -LiteralPath $lockFile) { Remove-Item -LiteralPath $lockFile -Force -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $tmpFile) { Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue }
}
if ($rc -ne 0) { exit 1 }
Write-Output ("[phase-status] Phase " + $Phase + " -> " + $Status + " in " + $planFile)
exit 0