243 lines
9.2 KiB
PowerShell
243 lines
9.2 KiB
PowerShell
# Check if all phases in task_plan.md are complete
|
|
# Default invocation: advisory echo, always exits 0 (Stop hook status report).
|
|
# With -Gate: deliberate completion gate, opt-in per plan via <plan-dir>/.mode.
|
|
# Used by Stop hook to report task completion status.
|
|
#
|
|
# Gate mode (v3, -Gate flag) blocks ONLY when ALL hold (design "Gate decision table"):
|
|
# 1. <plan-dir>/.mode exists and contains "gate" (explicit opt-in)
|
|
# 2. an in_progress phase exists (not merely complete<total)
|
|
# 3. the Stop hook input JSON on stdin does not set stop_hook_active=true
|
|
# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
|
|
# 5. the ledger advanced since the last block (stall -> allow stop)
|
|
# When all hold, emits a single-line block-decision JSON on stdout and exits 0.
|
|
# Otherwise advisory output and exit 0. Without -Gate, byte-equivalent to v2.43.
|
|
#
|
|
# Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an
|
|
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
|
|
|
param(
|
|
[string]$PlanFile = "",
|
|
[switch]$Gate
|
|
)
|
|
|
|
if ($PlanFile -ne "") {
|
|
$PlanDir = Split-Path -Parent $PlanFile
|
|
if ($PlanDir -eq "") { $PlanDir = "." }
|
|
} else {
|
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
$resolver = Join-Path $scriptDir "resolve-plan-dir.ps1"
|
|
$resolvedDir = ""
|
|
if (Test-Path $resolver) {
|
|
try {
|
|
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
|
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
|
} catch {
|
|
$resolvedDir = ""
|
|
}
|
|
}
|
|
if ($resolvedDir -ne "" -and (Test-Path (Join-Path $resolvedDir "task_plan.md"))) {
|
|
$PlanFile = Join-Path $resolvedDir "task_plan.md"
|
|
$PlanDir = $resolvedDir
|
|
} else {
|
|
$PlanFile = "task_plan.md"
|
|
$PlanDir = "."
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path $PlanFile)) {
|
|
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
|
|
exit 0
|
|
}
|
|
|
|
# Read file content
|
|
$content = Get-Content $PlanFile -Raw
|
|
|
|
# Count total phases
|
|
$TOTAL = ([regex]::Matches($content, "### Phase")).Count
|
|
|
|
# Count both formats per field and keep the larger of the two. A plan may mix
|
|
# '**Status:** pending' on one phase with '[in_progress]' on another; counting
|
|
# only the primary format (and falling back to inline ONLY when all three
|
|
# primaries are zero) lost the inline count and let an in_progress plan slip
|
|
# past the gate. Per-field max preserves the legacy single-format result
|
|
# (the other format contributes 0) while catching mixed plans.
|
|
$completePrimary = ([regex]::Matches($content, "\*\*Status:\*\* complete")).Count
|
|
$inProgressPrimary = ([regex]::Matches($content, "\*\*Status:\*\* in_progress")).Count
|
|
$pendingPrimary = ([regex]::Matches($content, "\*\*Status:\*\* pending")).Count
|
|
|
|
$completeInline = ([regex]::Matches($content, "\[complete\]")).Count
|
|
$inProgressInline = ([regex]::Matches($content, "\[in_progress\]")).Count
|
|
$pendingInline = ([regex]::Matches($content, "\[pending\]")).Count
|
|
|
|
$COMPLETE = [Math]::Max($completePrimary, $completeInline)
|
|
$IN_PROGRESS = [Math]::Max($inProgressPrimary, $inProgressInline)
|
|
$PENDING = [Math]::Max($pendingPrimary, $pendingInline)
|
|
|
|
# advisory_report: the v2.43 status echo.
|
|
function Write-AdvisoryReport {
|
|
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
|
Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')
|
|
} else {
|
|
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
|
if ($IN_PROGRESS -gt 0) {
|
|
Write-Host ('[planning-with-files] ' + $IN_PROGRESS + ' phase(s) still in progress.')
|
|
}
|
|
if ($PENDING -gt 0) {
|
|
Write-Host ('[planning-with-files] ' + $PENDING + ' phase(s) pending.')
|
|
}
|
|
}
|
|
}
|
|
|
|
# ---- Default (advisory) path: byte-equivalent to v2.43 ----
|
|
if (-not $Gate) {
|
|
Write-AdvisoryReport
|
|
exit 0
|
|
}
|
|
|
|
# ---- Gate path (-Gate). Resolves to advisory unless every guard says block. ----
|
|
|
|
# Guard 1: gated mode. The .mode file must contain "gate".
|
|
$modeFile = Join-Path $PlanDir ".mode"
|
|
$gatedMode = $false
|
|
if (Test-Path $modeFile) {
|
|
$modeContent = Get-Content $modeFile -Raw -ErrorAction SilentlyContinue
|
|
if ($null -ne $modeContent -and $modeContent -match "gate") {
|
|
$gatedMode = $true
|
|
}
|
|
}
|
|
if (-not $gatedMode) {
|
|
Write-AdvisoryReport
|
|
exit 0
|
|
}
|
|
|
|
# Guard 3: stop_hook_active. Read stdin only when input is redirected, so an
|
|
# interactive console never blocks. A true value means we are already inside a
|
|
# forced continuation; allow the stop.
|
|
$stdinJson = ""
|
|
try {
|
|
if ([Console]::IsInputRedirected) {
|
|
$stdinJson = [Console]::In.ReadToEnd()
|
|
}
|
|
} catch {
|
|
$stdinJson = ""
|
|
}
|
|
# Anchor on the literal value: "stop_hook_active" then colon then exactly true,
|
|
# with a JSON-structural boundary after it (whitespace, comma, closing brace, or
|
|
# end of input). Without the boundary 'true' could match a longer token; the
|
|
# boundary keeps a 'false' value (or any other key set to true) from tripping
|
|
# the guard and silently disabling the gate.
|
|
if ($stdinJson -match '"stop_hook_active"\s*:\s*true(\s|,|}|$)') {
|
|
Write-AdvisoryReport
|
|
exit 0
|
|
}
|
|
|
|
# Guard 2: an in_progress phase must exist.
|
|
if ($IN_PROGRESS -le 0) {
|
|
Write-AdvisoryReport
|
|
exit 0
|
|
}
|
|
|
|
# ledger_line_count: total lines across all <plan-dir>/ledger-*.jsonl files.
|
|
function Get-LedgerLineCount {
|
|
$total = 0
|
|
$files = Get-ChildItem -Path $PlanDir -Filter "ledger-*.jsonl" -File -ErrorAction SilentlyContinue
|
|
foreach ($f in $files) {
|
|
$lines = @(Get-Content $f.FullName -ErrorAction SilentlyContinue)
|
|
$total += $lines.Count
|
|
}
|
|
return $total
|
|
}
|
|
|
|
$cap = 20
|
|
if ($env:PWF_GATE_CAP -match '^\d+$') {
|
|
$cap = [int]$env:PWF_GATE_CAP
|
|
}
|
|
|
|
$blocksFile = Join-Path $PlanDir ".stop_blocks"
|
|
$blocks = 0
|
|
if (Test-Path $blocksFile) {
|
|
$raw = (Get-Content $blocksFile -Raw -ErrorAction SilentlyContinue)
|
|
if ($raw -match '^\s*(\d+)') { $blocks = [int]$Matches[1] }
|
|
}
|
|
|
|
$ledgerFile = Join-Path $PlanDir ".gate_last_ledger"
|
|
$ledgerPrev = 0
|
|
if (Test-Path $ledgerFile) {
|
|
$raw = (Get-Content $ledgerFile -Raw -ErrorAction SilentlyContinue)
|
|
if ($raw -match '^\s*(\d+)') { $ledgerPrev = [int]$Matches[1] }
|
|
}
|
|
$ledgerNow = Get-LedgerLineCount
|
|
|
|
# Guard 4: block-count cap.
|
|
if ($blocks -ge $cap) {
|
|
Write-AdvisoryReport
|
|
Write-Host ('[planning-with-files] gate cap reached (' + $blocks + '/' + $cap + ') -- allowing stop.')
|
|
exit 0
|
|
}
|
|
|
|
# Guard 5: stall detection.
|
|
if ($blocks -gt 0 -and $ledgerNow -eq $ledgerPrev) {
|
|
Write-AdvisoryReport
|
|
Write-Host '[planning-with-files] no progress since last gate block -- allowing stop.'
|
|
exit 0
|
|
}
|
|
|
|
# All guards passed: block the stop.
|
|
# Get-FirstInProgressPhase: heading text of the first phase whose Status is
|
|
# in_progress. Plain text only -- no plan body beyond the heading.
|
|
function Get-FirstInProgressPhase {
|
|
$heading = ""
|
|
foreach ($line in ($content -split "`n")) {
|
|
$trimmed = $line.TrimEnd("`r")
|
|
if ($trimmed -match '^### (.*)$') {
|
|
$heading = $Matches[1]
|
|
} elseif ($trimmed -match '\*\*Status:\*\* in_progress' -or $trimmed -match '\[in_progress\]') {
|
|
return $heading
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
$phaseName = Get-FirstInProgressPhase
|
|
if ($phaseName -eq "") { $phaseName = "unknown phase" }
|
|
|
|
# JSON-escape: backslash and double-quote, plus every bare control character
|
|
# JSON forbids (below 0x20) mapped to a space. A phase heading may carry a
|
|
# literal tab; left raw it produces invalid JSON the Stop hook rejects. Same
|
|
# logic as ledger-append.ps1 ConvertTo-JsonString.
|
|
function ConvertTo-JsonEscaped {
|
|
param([string] $Value)
|
|
$sb = New-Object System.Text.StringBuilder
|
|
foreach ($ch in $Value.ToCharArray()) {
|
|
switch ($ch) {
|
|
'"' { [void]$sb.Append('\"') }
|
|
'\' { [void]$sb.Append('\\') }
|
|
default {
|
|
if ([int]$ch -lt 32) {
|
|
[void]$sb.Append(' ')
|
|
} else {
|
|
[void]$sb.Append($ch)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $sb.ToString()
|
|
}
|
|
$phaseEscaped = ConvertTo-JsonEscaped $phaseName
|
|
|
|
$newBlocks = $blocks + 1
|
|
# Write sidecars as ASCII (single-byte digits) with an explicit LF and no BOM.
|
|
# Set-Content on Windows emits CRLF; check-complete.sh then reads '5\r', whose
|
|
# trailing CR makes the numeric guard reset BLOCKS to 0 on every cross-platform
|
|
# read, so the cap and stall guards never fire. WriteAllText with ASCII gives
|
|
# byte-for-byte '5\n' that both shells parse identically.
|
|
try { [System.IO.File]::WriteAllText($blocksFile, [string]$newBlocks + "`n", [System.Text.Encoding]::ASCII) } catch {}
|
|
try { [System.IO.File]::WriteAllText($ledgerFile, [string]$ledgerNow + "`n", [System.Text.Encoding]::ASCII) } catch {}
|
|
|
|
# Reason built from the JSON-escaped phase name; the surrounding template text
|
|
# has no quotes or backslashes, so only the heading needs escaping.
|
|
$reason = "[planning-with-files] Gated plan incomplete: phase '" + $phaseEscaped + "' is in_progress (" + $COMPLETE + "/" + $TOTAL + " complete, gate block " + $newBlocks + "/" + $cap + "). Finish or update the plan, then stop."
|
|
|
|
[Console]::Out.Write('{"decision":"block","reason":"' + $reason + '"}' + "`n")
|
|
exit 0
|