Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Structured planning workflow that uses files to track tasks, decisions, and project progress.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/check-complete.ps1
1# Check if all phases in task_plan.md are complete2# Default invocation: advisory echo, always exits 0 (Stop hook status report).3# With -Gate: deliberate completion gate, opt-in per plan via <plan-dir>/.mode.4# Used by Stop hook to report task completion status.5#6# Gate mode (v3, -Gate flag) blocks ONLY when ALL hold (design "Gate decision table"):7# 1. <plan-dir>/.mode exists and contains "gate" (explicit opt-in)8# 2. an in_progress phase exists (not merely complete<total)9# 3. the Stop hook input JSON on stdin does not set stop_hook_active=true10# 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)11# 5. the ledger advanced since the last block (stall -> allow stop)12# When all hold, emits a single-line block-decision JSON on stdout and exits 0.13# Otherwise advisory output and exit 0. Without -Gate, byte-equivalent to v2.43.14#15# Stdin: read only when input is redirected ([Console]::IsInputRedirected), so an16# interactive console never blocks. Hook-piped JSON is EOF-terminated.1718param(19[string]$PlanFile = "",20[switch]$Gate21)2223if ($PlanFile -ne "") {24$PlanDir = Split-Path -Parent $PlanFile25if ($PlanDir -eq "") { $PlanDir = "." }26} else {27$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path28$resolver = Join-Path $scriptDir "resolve-plan-dir.ps1"29$resolvedDir = ""30if (Test-Path $resolver) {31try {32$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)33if ($null -eq $resolvedDir) { $resolvedDir = "" }34} catch {35$resolvedDir = ""36}37}38if ($resolvedDir -ne "" -and (Test-Path (Join-Path $resolvedDir "task_plan.md"))) {39$PlanFile = Join-Path $resolvedDir "task_plan.md"40$PlanDir = $resolvedDir41} else {42$PlanFile = "task_plan.md"43$PlanDir = "."44}45}4647if (-not (Test-Path $PlanFile)) {48Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'49exit 050}5152# Read file content53$content = Get-Content $PlanFile -Raw5455# Count total phases56$TOTAL = ([regex]::Matches($content, "### Phase")).Count5758# Count both formats per field and keep the larger of the two. A plan may mix59# '**Status:** pending' on one phase with '[in_progress]' on another; counting60# only the primary format (and falling back to inline ONLY when all three61# primaries are zero) lost the inline count and let an in_progress plan slip62# past the gate. Per-field max preserves the legacy single-format result63# (the other format contributes 0) while catching mixed plans.64$completePrimary = ([regex]::Matches($content, "\*\*Status:\*\* complete")).Count65$inProgressPrimary = ([regex]::Matches($content, "\*\*Status:\*\* in_progress")).Count66$pendingPrimary = ([regex]::Matches($content, "\*\*Status:\*\* pending")).Count6768$completeInline = ([regex]::Matches($content, "\[complete\]")).Count69$inProgressInline = ([regex]::Matches($content, "\[in_progress\]")).Count70$pendingInline = ([regex]::Matches($content, "\[pending\]")).Count7172$COMPLETE = [Math]::Max($completePrimary, $completeInline)73$IN_PROGRESS = [Math]::Max($inProgressPrimary, $inProgressInline)74$PENDING = [Math]::Max($pendingPrimary, $pendingInline)7576# advisory_report: the v2.43 status echo.77function Write-AdvisoryReport {78if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {79Write-Host ('[planning-with-files] ALL PHASES COMPLETE (' + $COMPLETE + '/' + $TOTAL + '). If the user has additional work, add new phases to task_plan.md before starting.')80} else {81Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')82if ($IN_PROGRESS -gt 0) {83Write-Host ('[planning-with-files] ' + $IN_PROGRESS + ' phase(s) still in progress.')84}85if ($PENDING -gt 0) {86Write-Host ('[planning-with-files] ' + $PENDING + ' phase(s) pending.')87}88}89}9091# ---- Default (advisory) path: byte-equivalent to v2.43 ----92if (-not $Gate) {93Write-AdvisoryReport94exit 095}9697# ---- Gate path (-Gate). Resolves to advisory unless every guard says block. ----9899# Guard 1: gated mode. The .mode file must contain "gate".100$modeFile = Join-Path $PlanDir ".mode"101$gatedMode = $false102if (Test-Path $modeFile) {103$modeContent = Get-Content $modeFile -Raw -ErrorAction SilentlyContinue104if ($null -ne $modeContent -and $modeContent -match "gate") {105$gatedMode = $true106}107}108if (-not $gatedMode) {109Write-AdvisoryReport110exit 0111}112113# Guard 3: stop_hook_active. Read stdin only when input is redirected, so an114# interactive console never blocks. A true value means we are already inside a115# forced continuation; allow the stop.116$stdinJson = ""117try {118if ([Console]::IsInputRedirected) {119$stdinJson = [Console]::In.ReadToEnd()120}121} catch {122$stdinJson = ""123}124# Anchor on the literal value: "stop_hook_active" then colon then exactly true,125# with a JSON-structural boundary after it (whitespace, comma, closing brace, or126# end of input). Without the boundary 'true' could match a longer token; the127# boundary keeps a 'false' value (or any other key set to true) from tripping128# the guard and silently disabling the gate.129if ($stdinJson -match '"stop_hook_active"\s*:\s*true(\s|,|}|$)') {130Write-AdvisoryReport131exit 0132}133134# Guard 2: an in_progress phase must exist.135if ($IN_PROGRESS -le 0) {136Write-AdvisoryReport137exit 0138}139140# ledger_line_count: total lines across all <plan-dir>/ledger-*.jsonl files.141function Get-LedgerLineCount {142$total = 0143$files = Get-ChildItem -Path $PlanDir -Filter "ledger-*.jsonl" -File -ErrorAction SilentlyContinue144foreach ($f in $files) {145$lines = @(Get-Content $f.FullName -ErrorAction SilentlyContinue)146$total += $lines.Count147}148return $total149}150151$cap = 20152if ($env:PWF_GATE_CAP -match '^\d+$') {153$cap = [int]$env:PWF_GATE_CAP154}155156$blocksFile = Join-Path $PlanDir ".stop_blocks"157$blocks = 0158if (Test-Path $blocksFile) {159$raw = (Get-Content $blocksFile -Raw -ErrorAction SilentlyContinue)160if ($raw -match '^\s*(\d+)') { $blocks = [int]$Matches[1] }161}162163$ledgerFile = Join-Path $PlanDir ".gate_last_ledger"164$ledgerPrev = 0165if (Test-Path $ledgerFile) {166$raw = (Get-Content $ledgerFile -Raw -ErrorAction SilentlyContinue)167if ($raw -match '^\s*(\d+)') { $ledgerPrev = [int]$Matches[1] }168}169$ledgerNow = Get-LedgerLineCount170171# Guard 4: block-count cap.172if ($blocks -ge $cap) {173Write-AdvisoryReport174Write-Host ('[planning-with-files] gate cap reached (' + $blocks + '/' + $cap + ') -- allowing stop.')175exit 0176}177178# Guard 5: stall detection.179if ($blocks -gt 0 -and $ledgerNow -eq $ledgerPrev) {180Write-AdvisoryReport181Write-Host '[planning-with-files] no progress since last gate block -- allowing stop.'182exit 0183}184185# All guards passed: block the stop.186# Get-FirstInProgressPhase: heading text of the first phase whose Status is187# in_progress. Plain text only -- no plan body beyond the heading.188function Get-FirstInProgressPhase {189$heading = ""190foreach ($line in ($content -split "`n")) {191$trimmed = $line.TrimEnd("`r")192if ($trimmed -match '^### (.*)$') {193$heading = $Matches[1]194} elseif ($trimmed -match '\*\*Status:\*\* in_progress' -or $trimmed -match '\[in_progress\]') {195return $heading196}197}198return ""199}200201$phaseName = Get-FirstInProgressPhase202if ($phaseName -eq "") { $phaseName = "unknown phase" }203204# JSON-escape: backslash and double-quote, plus every bare control character205# JSON forbids (below 0x20) mapped to a space. A phase heading may carry a206# literal tab; left raw it produces invalid JSON the Stop hook rejects. Same207# logic as ledger-append.ps1 ConvertTo-JsonString.208function ConvertTo-JsonEscaped {209param([string] $Value)210$sb = New-Object System.Text.StringBuilder211foreach ($ch in $Value.ToCharArray()) {212switch ($ch) {213'"' { [void]$sb.Append('\"') }214'\' { [void]$sb.Append('\\') }215default {216if ([int]$ch -lt 32) {217[void]$sb.Append(' ')218} else {219[void]$sb.Append($ch)220}221}222}223}224return $sb.ToString()225}226$phaseEscaped = ConvertTo-JsonEscaped $phaseName227228$newBlocks = $blocks + 1229# Write sidecars as ASCII (single-byte digits) with an explicit LF and no BOM.230# Set-Content on Windows emits CRLF; check-complete.sh then reads '5\r', whose231# trailing CR makes the numeric guard reset BLOCKS to 0 on every cross-platform232# read, so the cap and stall guards never fire. WriteAllText with ASCII gives233# byte-for-byte '5\n' that both shells parse identically.234try { [System.IO.File]::WriteAllText($blocksFile, [string]$newBlocks + "`n", [System.Text.Encoding]::ASCII) } catch {}235try { [System.IO.File]::WriteAllText($ledgerFile, [string]$ledgerNow + "`n", [System.Text.Encoding]::ASCII) } catch {}236237# Reason built from the JSON-escaped phase name; the surrounding template text238# has no quotes or backslashes, so only the heading needs escaping.239$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."240241[Console]::Out.Write('{"decision":"block","reason":"' + $reason + '"}' + "`n")242exit 0243