mirror of
https://github.com/OthmanAdi/planning-with-files.git
synced 2026-09-14 16:09:40 +08:00
3749f86ac3
Three defects reported by Raymond Manaloto (@sortakool), each reproduced on Windows with Git Bash before any code changed. Two of them let a session work on a plan nobody selected; the third made the diagnostic that should have caught them report PASS. closes #237: an explicit PLAN_ID was a hint, not a binding resolve_from_env returned 1 both when no selector was set and when the selector was rejected, so a PLAN_ID of valid slug shape naming no directory fell through to .active_plan and then to newest-by-mtime. attest-plan.sh locked that other plan at rc=0 and injection followed it. The guard in attest-plan.sh written for exactly this case was unreachable, because the resolver never returned empty. commands/plan-attest.md has promised the opposite since v3.9.0. A non-empty PLAN_ID now resolves or stops, for every rejection reason: slug shape (traversal included), naming no directory, or failing containment. An empty value still means unset, which init-session.sh depends on. The rule went into every resolver, because they disagreed otherwise: resolve-plan-dir.sh, resolve-plan-dir.ps1, the inline resolver inside inject-plan.sh, and the Hermes, OpenCode and Pi plugin resolvers. v3.14.0 had made the two plugin resolvers fall through to match the shell; that parity was right and its target was wrong. Fixing the resolver alone would have moved the bug rather than removed it, so every consumer was audited. check-complete.sh would have read the root plan's completion state, which decides whether an autonomous run may stop. ledger-summary.sh feeds phase counts into autonomous turns. phase-status.sh and ledger-append.sh write into the directory they pick. The five Codex hooks call the resolver directly and each fell through to the legacy root, stop.sh and pre-compact.sh included. All of them now stop on a rejected selector, and the once-per-turn routes print a notice, because a fail-closed path with no stated cause would trade a wrong-plan bug for a silent one. closes #238: a slug plan with no .mode bypassed the project's root .mode A project makes attestation mandatory by committing a root .mode, a reviewed project setting. inject-plan.sh read the slug's .mode and never the root's, and init-session.sh writes no .mode unless --autonomous or --gated was passed, so init-session.sh <name>, which /plan runs, produced a plan with no attestation requirement and full injection. The policy was a flag the agent chose at plan creation. The root .mode is now a floor. Strictness-raising tokens count from either file, so a slug may raise and never lower; the one strictness-lowering token, plan-guard-off, needs the root's agreement. The same floor applies to the completion gate in check-complete.sh and check-complete.ps1, which read .mode the same way and had the same bypass, and to the Hermes and OpenCode plugins. init-session.sh seeds a new slug from the root .mode so the effective policy is visible in the plan directory, not only in the resolver. With no root .mode present the effective token set is exactly the slug's, so existing projects are unchanged. closes #236: plan-doctor.sh reported PASS on a fully dark-hooks state The injection check substring-matched five control strings against output that carries the plan body verbatim inside the BEGIN-PWF-DATA fences. A plan whose phase line read "fix the false PLAN TAMPERED warning in plan-doctor" reported a hash mismatch while correctly attested. Worse, the PWF_PLAN_ROOT arm matched a literal that is not a substring of what inject-plan.sh emits, so the arm was dead code and execution fell to the success arm: PWF_PLAN_ROOT=/nonexistent printed "PASS injection: emits plan context (123 bytes)" while nothing was injected, counting the refusal notice's own bytes as plan context. The file's own comment four lines above already warned against exactly that. Classification now branches on the frame. Every refusal path exits before frame_file runs, so a frame proves injection happened and unframed output is by construction a notice. The default arm warns, so a reworded or translated banner degrades noisily instead of silently. The stale literal is corrected against the emitting echo. Tests: Python suite 653 to 696, OpenCode Vitest 28 to 34. New files test_plan_selector_binding.py, test_root_mode_floor.py and test_plan_doctor_classification.py; new arms in test_resolver_parity.py, test_resolve_plan_dir.py, test_resolver_plan_root_pin.py and test_hermes_first_class.py. Five existing tests asserted the fall-through and were rewritten to assert the refusal, keeping their original security assertion. Each suite carries a control arm: two first drafts passed on fixtures that could not have failed, a gate assertion over a plan with every phase complete and a parallel-write-guard assertion over a plan with nothing checked to lose.
248 lines
9.2 KiB
PowerShell
248 lines
9.2 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 the portable
|
|
<plan-dir>\.pwf-locks\phase-status.lock directory lock, 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"
|
|
|
|
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that
|
|
# names no plan directory stops resolution instead of falling through to
|
|
# .active_plan and newest-by-mtime: this script reports phase state, and
|
|
# answering a mistyped pin with a DIFFERENT plan's phases is the same
|
|
# wrong-plan harm that let a typo attest the wrong file.
|
|
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 }
|
|
return $null
|
|
}
|
|
|
|
$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
|
|
}
|
|
|
|
function Enter-PwfDirectoryLock {
|
|
param(
|
|
[string] $LockRoot,
|
|
[string] $LockDir
|
|
)
|
|
|
|
try {
|
|
[void][System.IO.Directory]::CreateDirectory($LockRoot)
|
|
} catch {
|
|
Write-Error ("[phase-status] Cannot create lock root " + $LockRoot + ": " + $_.Exception.Message)
|
|
return $null
|
|
}
|
|
|
|
$token = "phase-status-" + $PID + "-" + [Guid]::NewGuid().ToString("N")
|
|
$ownerFile = Join-Path $LockDir ".owner"
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
|
|
$wait = [Diagnostics.Stopwatch]::StartNew()
|
|
while ($wait.Elapsed.TotalSeconds -lt 5) {
|
|
$createdByUs = $false
|
|
try {
|
|
New-Item -Path $LockDir -ItemType Directory -ErrorAction Stop | Out-Null
|
|
$createdByUs = $true
|
|
[System.IO.File]::WriteAllText($ownerFile, $token + "`n", $utf8NoBom)
|
|
return [PSCustomObject]@{
|
|
Directory = $LockDir
|
|
OwnerFile = $ownerFile
|
|
Token = $token
|
|
}
|
|
} catch {
|
|
if ($createdByUs) {
|
|
try {
|
|
if ([System.IO.File]::Exists($ownerFile)) {
|
|
$ownerValue = [System.IO.File]::ReadAllText($ownerFile).Trim()
|
|
if ([string]::Equals($ownerValue, $token, [StringComparison]::Ordinal)) {
|
|
[System.IO.File]::Delete($ownerFile)
|
|
}
|
|
}
|
|
[System.IO.Directory]::Delete($LockDir, $false)
|
|
} catch {
|
|
# Leave any directory we cannot prove is still ours intact.
|
|
}
|
|
}
|
|
Start-Sleep -Milliseconds 100
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
function Exit-PwfDirectoryLock {
|
|
param($Lock)
|
|
if (-not $Lock) { return }
|
|
try {
|
|
if (-not [System.IO.File]::Exists($Lock.OwnerFile)) { return }
|
|
$ownerValue = [System.IO.File]::ReadAllText($Lock.OwnerFile).Trim()
|
|
if (-not [string]::Equals($ownerValue, $Lock.Token, [StringComparison]::Ordinal)) { return }
|
|
[System.IO.File]::Delete($Lock.OwnerFile)
|
|
[System.IO.Directory]::Delete($Lock.Directory, $false)
|
|
} catch {
|
|
# Cleanup is best-effort and never removes a lock with another owner.
|
|
}
|
|
}
|
|
|
|
# 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) {
|
|
if ($env:PLAN_ID) {
|
|
Write-Error "[phase-status] PLAN_ID names no plan directory under .planning; nothing was written and no other plan was substituted."
|
|
} else {
|
|
Write-Error "[phase-status] No task_plan.md found. Create a plan first."
|
|
}
|
|
exit 1
|
|
}
|
|
|
|
$planDir = Split-Path -Parent $planFile
|
|
$lockRoot = Join-Path $planDir ".pwf-locks"
|
|
$lockDir = Join-Path $lockRoot "phase-status.lock"
|
|
|
|
# Atomic directory creation is the common lock primitive used by both the sh
|
|
# and PowerShell implementations. Failure to acquire within about five seconds
|
|
# is fail-closed: no plan read/rewrite is attempted.
|
|
$lock = Enter-PwfDirectoryLock -LockRoot $lockRoot -LockDir $lockDir
|
|
if (-not $lock) {
|
|
Write-Error ("[phase-status] Timed out waiting for lock " + $lockDir + ". No plan changes were made.")
|
|
exit 75
|
|
}
|
|
|
|
$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 {
|
|
Exit-PwfDirectoryLock -Lock $lock
|
|
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
|