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.
- package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -228
- package/extensions/planning-with-files/package.json +17 -17
- package/extensions/planning-with-files/runtime.ts +788 -788
- package/package.json +1 -1
- package/scripts/attest-plan.ps1 +137 -137
- package/scripts/attest-plan.sh +206 -206
- package/scripts/check-complete.ps1 +253 -253
- package/scripts/check-complete.sh +253 -253
- package/scripts/gate-stop.sh +32 -32
- package/scripts/ledger-append.ps1 +180 -180
- package/scripts/ledger-append.sh +337 -337
- package/scripts/ledger-summary.ps1 +128 -128
- package/scripts/phase-status.ps1 +175 -175
- package/scripts/phase-status.sh +158 -158
- package/scripts/session-catchup.py +876 -876
- package/scripts/set-active-plan.ps1 +51 -51
- package/scripts/set-active-plan.sh +50 -50
|
@@ -1,253 +1,253 @@
|
|
|
1
|
-
# Check if all phases in task_plan.md are complete
|
|
2
|
-
# 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=true
|
|
10
|
-
# 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 an
|
|
16
|
-
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
|
17
|
-
|
|
18
|
-
param(
|
|
19
|
-
[string]$PlanFile = "",
|
|
20
|
-
[switch]$Gate
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
# issue #195: per-invocation opt-out (PLANNING_DISABLED=1) for one-shot/CI
|
|
24
|
-
# sessions that share a cwd with a plan but never opted into it.
|
|
25
|
-
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
|
|
26
|
-
|
|
27
|
-
if ($PlanFile -ne "") {
|
|
28
|
-
$PlanDir = Split-Path -Parent $PlanFile
|
|
29
|
-
if ($PlanDir -eq "") { $PlanDir = "." }
|
|
30
|
-
} else {
|
|
31
|
-
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
32
|
-
$resolver = Join-Path $scriptDir "resolve-plan-dir.ps1"
|
|
33
|
-
$resolvedDir = ""
|
|
34
|
-
if (Test-Path $resolver) {
|
|
35
|
-
try {
|
|
36
|
-
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
|
37
|
-
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
|
38
|
-
} catch {
|
|
39
|
-
$resolvedDir = ""
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if ($resolvedDir -ne "" -and (Test-Path (Join-Path $resolvedDir "task_plan.md"))) {
|
|
43
|
-
$PlanFile = Join-Path $resolvedDir "task_plan.md"
|
|
44
|
-
$PlanDir = $resolvedDir
|
|
45
|
-
} else {
|
|
46
|
-
$PlanFile = "task_plan.md"
|
|
47
|
-
$PlanDir = "."
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (-not (Test-Path $PlanFile)) {
|
|
52
|
-
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
|
|
53
|
-
exit 0
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
# Read file content
|
|
57
|
-
$content = Get-Content $PlanFile -Raw
|
|
58
|
-
|
|
59
|
-
# Count total phases
|
|
60
|
-
$TOTAL = ([regex]::Matches($content, "### Phase")).Count
|
|
61
|
-
|
|
62
|
-
# Count both formats per field and keep the larger of the two. A plan may mix
|
|
63
|
-
# '**Status:** pending' on one phase with '[in_progress]' on another; counting
|
|
64
|
-
# only the primary format (and falling back to inline ONLY when all three
|
|
65
|
-
# primaries are zero) lost the inline count and let an in_progress plan slip
|
|
66
|
-
# past the gate. Per-field max preserves the legacy single-format result
|
|
67
|
-
# (the other format contributes 0) while catching mixed plans.
|
|
68
|
-
$completePrimary = ([regex]::Matches($content, "\*\*Status:\*\* complete")).Count
|
|
69
|
-
$inProgressPrimary = ([regex]::Matches($content, "\*\*Status:\*\* in_progress")).Count
|
|
70
|
-
$pendingPrimary = ([regex]::Matches($content, "\*\*Status:\*\* pending")).Count
|
|
71
|
-
|
|
72
|
-
$completeInline = ([regex]::Matches($content, "\[complete\]")).Count
|
|
73
|
-
$inProgressInline = ([regex]::Matches($content, "\[in_progress\]")).Count
|
|
74
|
-
$pendingInline = ([regex]::Matches($content, "\[pending\]")).Count
|
|
75
|
-
|
|
76
|
-
$COMPLETE = [Math]::Max($completePrimary, $completeInline)
|
|
77
|
-
$IN_PROGRESS = [Math]::Max($inProgressPrimary, $inProgressInline)
|
|
78
|
-
$PENDING = [Math]::Max($pendingPrimary, $pendingInline)
|
|
79
|
-
|
|
80
|
-
# issue #191: no "### Phase" headings -> not a phase-structured plan. Report
|
|
81
|
-
# nothing rather than a false "0/0 phases complete" status. With TOTAL=0 the
|
|
82
|
-
# gate can never legitimately block (IN_PROGRESS is also 0), so exit is safe.
|
|
83
|
-
if ($TOTAL -eq 0) {
|
|
84
|
-
exit 0
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
# advisory_report: the v2.43 status echo.
|
|
88
|
-
function Write-AdvisoryReport {
|
|
89
|
-
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
|
90
|
-
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.')
|
|
91
|
-
} else {
|
|
92
|
-
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
|
93
|
-
if ($IN_PROGRESS -gt 0) {
|
|
94
|
-
Write-Host ('[planning-with-files] ' + $IN_PROGRESS + ' phase(s) still in progress.')
|
|
95
|
-
}
|
|
96
|
-
if ($PENDING -gt 0) {
|
|
97
|
-
Write-Host ('[planning-with-files] ' + $PENDING + ' phase(s) pending.')
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
# ---- Default (advisory) path: byte-equivalent to v2.43 ----
|
|
103
|
-
if (-not $Gate) {
|
|
104
|
-
Write-AdvisoryReport
|
|
105
|
-
exit 0
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
# ---- Gate path (-Gate). Resolves to advisory unless every guard says block. ----
|
|
109
|
-
|
|
110
|
-
# Guard 1: gated mode. The .mode file must contain "gate".
|
|
111
|
-
$modeFile = Join-Path $PlanDir ".mode"
|
|
112
|
-
$gatedMode = $false
|
|
113
|
-
if (Test-Path $modeFile) {
|
|
114
|
-
$modeContent = Get-Content $modeFile -Raw -ErrorAction SilentlyContinue
|
|
115
|
-
if ($null -ne $modeContent -and $modeContent -match "gate") {
|
|
116
|
-
$gatedMode = $true
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
if (-not $gatedMode) {
|
|
120
|
-
Write-AdvisoryReport
|
|
121
|
-
exit 0
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
# Guard 3: stop_hook_active. Read stdin only when input is redirected, so an
|
|
125
|
-
# interactive console never blocks. A true value means we are already inside a
|
|
126
|
-
# forced continuation; allow the stop.
|
|
127
|
-
$stdinJson = ""
|
|
128
|
-
try {
|
|
129
|
-
if ([Console]::IsInputRedirected) {
|
|
130
|
-
$stdinJson = [Console]::In.ReadToEnd()
|
|
131
|
-
}
|
|
132
|
-
} catch {
|
|
133
|
-
$stdinJson = ""
|
|
134
|
-
}
|
|
135
|
-
# Anchor on the literal value: "stop_hook_active" then colon then exactly true,
|
|
136
|
-
# with a JSON-structural boundary after it (whitespace, comma, closing brace, or
|
|
137
|
-
# end of input). Without the boundary 'true' could match a longer token; the
|
|
138
|
-
# boundary keeps a 'false' value (or any other key set to true) from tripping
|
|
139
|
-
# the guard and silently disabling the gate.
|
|
140
|
-
if ($stdinJson -match '"stop_hook_active"\s*:\s*true(\s|,|}|$)') {
|
|
141
|
-
Write-AdvisoryReport
|
|
142
|
-
exit 0
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
# Guard 2: an in_progress phase must exist.
|
|
146
|
-
if ($IN_PROGRESS -le 0) {
|
|
147
|
-
Write-AdvisoryReport
|
|
148
|
-
exit 0
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
# ledger_line_count: total lines across all <plan-dir>/ledger-*.jsonl files.
|
|
152
|
-
function Get-LedgerLineCount {
|
|
153
|
-
$total = 0
|
|
154
|
-
$files = Get-ChildItem -Path $PlanDir -Filter "ledger-*.jsonl" -File -ErrorAction SilentlyContinue
|
|
155
|
-
foreach ($f in $files) {
|
|
156
|
-
$lines = @(Get-Content $f.FullName -ErrorAction SilentlyContinue)
|
|
157
|
-
$total += $lines.Count
|
|
158
|
-
}
|
|
159
|
-
return $total
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
$cap = 20
|
|
163
|
-
if ($env:PWF_GATE_CAP -match '^\d+$') {
|
|
164
|
-
$cap = [int]$env:PWF_GATE_CAP
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
$blocksFile = Join-Path $PlanDir ".stop_blocks"
|
|
168
|
-
$blocks = 0
|
|
169
|
-
if (Test-Path $blocksFile) {
|
|
170
|
-
$raw = (Get-Content $blocksFile -Raw -ErrorAction SilentlyContinue)
|
|
171
|
-
if ($raw -match '^\s*(\d+)') { $blocks = [int]$Matches[1] }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
$ledgerFile = Join-Path $PlanDir ".gate_last_ledger"
|
|
175
|
-
$ledgerPrev = 0
|
|
176
|
-
if (Test-Path $ledgerFile) {
|
|
177
|
-
$raw = (Get-Content $ledgerFile -Raw -ErrorAction SilentlyContinue)
|
|
178
|
-
if ($raw -match '^\s*(\d+)') { $ledgerPrev = [int]$Matches[1] }
|
|
179
|
-
}
|
|
180
|
-
$ledgerNow = Get-LedgerLineCount
|
|
181
|
-
|
|
182
|
-
# Guard 4: block-count cap.
|
|
183
|
-
if ($blocks -ge $cap) {
|
|
184
|
-
Write-AdvisoryReport
|
|
185
|
-
Write-Host ('[planning-with-files] gate cap reached (' + $blocks + '/' + $cap + ') -- allowing stop.')
|
|
186
|
-
exit 0
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
# Guard 5: stall detection.
|
|
190
|
-
if ($blocks -gt 0 -and $ledgerNow -eq $ledgerPrev) {
|
|
191
|
-
Write-AdvisoryReport
|
|
192
|
-
Write-Host '[planning-with-files] no progress since last gate block -- allowing stop.'
|
|
193
|
-
exit 0
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
# All guards passed: block the stop.
|
|
197
|
-
# Get-FirstInProgressPhase: heading text of the first phase whose Status is
|
|
198
|
-
# in_progress. Plain text only -- no plan body beyond the heading.
|
|
199
|
-
function Get-FirstInProgressPhase {
|
|
200
|
-
$heading = ""
|
|
201
|
-
foreach ($line in ($content -split "`n")) {
|
|
202
|
-
$trimmed = $line.TrimEnd("`r")
|
|
203
|
-
if ($trimmed -match '^### (.*)$') {
|
|
204
|
-
$heading = $Matches[1]
|
|
205
|
-
} elseif ($trimmed -match '\*\*Status:\*\* in_progress' -or $trimmed -match '\[in_progress\]') {
|
|
206
|
-
return $heading
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return ""
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
$phaseName = Get-FirstInProgressPhase
|
|
213
|
-
if ($phaseName -eq "") { $phaseName = "unknown phase" }
|
|
214
|
-
|
|
215
|
-
# JSON-escape: backslash and double-quote, plus every bare control character
|
|
216
|
-
# JSON forbids (below 0x20) mapped to a space. A phase heading may carry a
|
|
217
|
-
# literal tab; left raw it produces invalid JSON the Stop hook rejects. Same
|
|
218
|
-
# logic as ledger-append.ps1 ConvertTo-JsonString.
|
|
219
|
-
function ConvertTo-JsonEscaped {
|
|
220
|
-
param([string] $Value)
|
|
221
|
-
$sb = New-Object System.Text.StringBuilder
|
|
222
|
-
foreach ($ch in $Value.ToCharArray()) {
|
|
223
|
-
switch ($ch) {
|
|
224
|
-
'"' { [void]$sb.Append('\"') }
|
|
225
|
-
'\' { [void]$sb.Append('\\') }
|
|
226
|
-
default {
|
|
227
|
-
if ([int]$ch -lt 32) {
|
|
228
|
-
[void]$sb.Append(' ')
|
|
229
|
-
} else {
|
|
230
|
-
[void]$sb.Append($ch)
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return $sb.ToString()
|
|
236
|
-
}
|
|
237
|
-
$phaseEscaped = ConvertTo-JsonEscaped $phaseName
|
|
238
|
-
|
|
239
|
-
$newBlocks = $blocks + 1
|
|
240
|
-
# Write sidecars as ASCII (single-byte digits) with an explicit LF and no BOM.
|
|
241
|
-
# Set-Content on Windows emits CRLF; check-complete.sh then reads '5\r', whose
|
|
242
|
-
# trailing CR makes the numeric guard reset BLOCKS to 0 on every cross-platform
|
|
243
|
-
# read, so the cap and stall guards never fire. WriteAllText with ASCII gives
|
|
244
|
-
# byte-for-byte '5\n' that both shells parse identically.
|
|
245
|
-
try { [System.IO.File]::WriteAllText($blocksFile, [string]$newBlocks + "`n", [System.Text.Encoding]::ASCII) } catch {}
|
|
246
|
-
try { [System.IO.File]::WriteAllText($ledgerFile, [string]$ledgerNow + "`n", [System.Text.Encoding]::ASCII) } catch {}
|
|
247
|
-
|
|
248
|
-
# Reason built from the JSON-escaped phase name; the surrounding template text
|
|
249
|
-
# has no quotes or backslashes, so only the heading needs escaping.
|
|
250
|
-
$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."
|
|
251
|
-
|
|
252
|
-
[Console]::Out.Write('{"decision":"block","reason":"' + $reason + '"}' + "`n")
|
|
253
|
-
exit 0
|
|
1
|
+
# Check if all phases in task_plan.md are complete
|
|
2
|
+
# 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=true
|
|
10
|
+
# 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 an
|
|
16
|
+
# interactive console never blocks. Hook-piped JSON is EOF-terminated.
|
|
17
|
+
|
|
18
|
+
param(
|
|
19
|
+
[string]$PlanFile = "",
|
|
20
|
+
[switch]$Gate
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# issue #195: per-invocation opt-out (PLANNING_DISABLED=1) for one-shot/CI
|
|
24
|
+
# sessions that share a cwd with a plan but never opted into it.
|
|
25
|
+
if ($env:PLANNING_DISABLED -eq '1') { exit 0 }
|
|
26
|
+
|
|
27
|
+
if ($PlanFile -ne "") {
|
|
28
|
+
$PlanDir = Split-Path -Parent $PlanFile
|
|
29
|
+
if ($PlanDir -eq "") { $PlanDir = "." }
|
|
30
|
+
} else {
|
|
31
|
+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
32
|
+
$resolver = Join-Path $scriptDir "resolve-plan-dir.ps1"
|
|
33
|
+
$resolvedDir = ""
|
|
34
|
+
if (Test-Path $resolver) {
|
|
35
|
+
try {
|
|
36
|
+
$resolvedDir = (& $resolver 2>$null | Select-Object -First 1)
|
|
37
|
+
if ($null -eq $resolvedDir) { $resolvedDir = "" }
|
|
38
|
+
} catch {
|
|
39
|
+
$resolvedDir = ""
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if ($resolvedDir -ne "" -and (Test-Path (Join-Path $resolvedDir "task_plan.md"))) {
|
|
43
|
+
$PlanFile = Join-Path $resolvedDir "task_plan.md"
|
|
44
|
+
$PlanDir = $resolvedDir
|
|
45
|
+
} else {
|
|
46
|
+
$PlanFile = "task_plan.md"
|
|
47
|
+
$PlanDir = "."
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (-not (Test-Path $PlanFile)) {
|
|
52
|
+
Write-Host '[planning-with-files] No task_plan.md found -- no active planning session.'
|
|
53
|
+
exit 0
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Read file content
|
|
57
|
+
$content = Get-Content $PlanFile -Raw
|
|
58
|
+
|
|
59
|
+
# Count total phases
|
|
60
|
+
$TOTAL = ([regex]::Matches($content, "### Phase")).Count
|
|
61
|
+
|
|
62
|
+
# Count both formats per field and keep the larger of the two. A plan may mix
|
|
63
|
+
# '**Status:** pending' on one phase with '[in_progress]' on another; counting
|
|
64
|
+
# only the primary format (and falling back to inline ONLY when all three
|
|
65
|
+
# primaries are zero) lost the inline count and let an in_progress plan slip
|
|
66
|
+
# past the gate. Per-field max preserves the legacy single-format result
|
|
67
|
+
# (the other format contributes 0) while catching mixed plans.
|
|
68
|
+
$completePrimary = ([regex]::Matches($content, "\*\*Status:\*\* complete")).Count
|
|
69
|
+
$inProgressPrimary = ([regex]::Matches($content, "\*\*Status:\*\* in_progress")).Count
|
|
70
|
+
$pendingPrimary = ([regex]::Matches($content, "\*\*Status:\*\* pending")).Count
|
|
71
|
+
|
|
72
|
+
$completeInline = ([regex]::Matches($content, "\[complete\]")).Count
|
|
73
|
+
$inProgressInline = ([regex]::Matches($content, "\[in_progress\]")).Count
|
|
74
|
+
$pendingInline = ([regex]::Matches($content, "\[pending\]")).Count
|
|
75
|
+
|
|
76
|
+
$COMPLETE = [Math]::Max($completePrimary, $completeInline)
|
|
77
|
+
$IN_PROGRESS = [Math]::Max($inProgressPrimary, $inProgressInline)
|
|
78
|
+
$PENDING = [Math]::Max($pendingPrimary, $pendingInline)
|
|
79
|
+
|
|
80
|
+
# issue #191: no "### Phase" headings -> not a phase-structured plan. Report
|
|
81
|
+
# nothing rather than a false "0/0 phases complete" status. With TOTAL=0 the
|
|
82
|
+
# gate can never legitimately block (IN_PROGRESS is also 0), so exit is safe.
|
|
83
|
+
if ($TOTAL -eq 0) {
|
|
84
|
+
exit 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# advisory_report: the v2.43 status echo.
|
|
88
|
+
function Write-AdvisoryReport {
|
|
89
|
+
if ($COMPLETE -eq $TOTAL -and $TOTAL -gt 0) {
|
|
90
|
+
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.')
|
|
91
|
+
} else {
|
|
92
|
+
Write-Host ('[planning-with-files] Task in progress (' + $COMPLETE + '/' + $TOTAL + ' phases complete). Update progress.md before stopping.')
|
|
93
|
+
if ($IN_PROGRESS -gt 0) {
|
|
94
|
+
Write-Host ('[planning-with-files] ' + $IN_PROGRESS + ' phase(s) still in progress.')
|
|
95
|
+
}
|
|
96
|
+
if ($PENDING -gt 0) {
|
|
97
|
+
Write-Host ('[planning-with-files] ' + $PENDING + ' phase(s) pending.')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# ---- Default (advisory) path: byte-equivalent to v2.43 ----
|
|
103
|
+
if (-not $Gate) {
|
|
104
|
+
Write-AdvisoryReport
|
|
105
|
+
exit 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# ---- Gate path (-Gate). Resolves to advisory unless every guard says block. ----
|
|
109
|
+
|
|
110
|
+
# Guard 1: gated mode. The .mode file must contain "gate".
|
|
111
|
+
$modeFile = Join-Path $PlanDir ".mode"
|
|
112
|
+
$gatedMode = $false
|
|
113
|
+
if (Test-Path $modeFile) {
|
|
114
|
+
$modeContent = Get-Content $modeFile -Raw -ErrorAction SilentlyContinue
|
|
115
|
+
if ($null -ne $modeContent -and $modeContent -match "gate") {
|
|
116
|
+
$gatedMode = $true
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (-not $gatedMode) {
|
|
120
|
+
Write-AdvisoryReport
|
|
121
|
+
exit 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# Guard 3: stop_hook_active. Read stdin only when input is redirected, so an
|
|
125
|
+
# interactive console never blocks. A true value means we are already inside a
|
|
126
|
+
# forced continuation; allow the stop.
|
|
127
|
+
$stdinJson = ""
|
|
128
|
+
try {
|
|
129
|
+
if ([Console]::IsInputRedirected) {
|
|
130
|
+
$stdinJson = [Console]::In.ReadToEnd()
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
$stdinJson = ""
|
|
134
|
+
}
|
|
135
|
+
# Anchor on the literal value: "stop_hook_active" then colon then exactly true,
|
|
136
|
+
# with a JSON-structural boundary after it (whitespace, comma, closing brace, or
|
|
137
|
+
# end of input). Without the boundary 'true' could match a longer token; the
|
|
138
|
+
# boundary keeps a 'false' value (or any other key set to true) from tripping
|
|
139
|
+
# the guard and silently disabling the gate.
|
|
140
|
+
if ($stdinJson -match '"stop_hook_active"\s*:\s*true(\s|,|}|$)') {
|
|
141
|
+
Write-AdvisoryReport
|
|
142
|
+
exit 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Guard 2: an in_progress phase must exist.
|
|
146
|
+
if ($IN_PROGRESS -le 0) {
|
|
147
|
+
Write-AdvisoryReport
|
|
148
|
+
exit 0
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# ledger_line_count: total lines across all <plan-dir>/ledger-*.jsonl files.
|
|
152
|
+
function Get-LedgerLineCount {
|
|
153
|
+
$total = 0
|
|
154
|
+
$files = Get-ChildItem -Path $PlanDir -Filter "ledger-*.jsonl" -File -ErrorAction SilentlyContinue
|
|
155
|
+
foreach ($f in $files) {
|
|
156
|
+
$lines = @(Get-Content $f.FullName -ErrorAction SilentlyContinue)
|
|
157
|
+
$total += $lines.Count
|
|
158
|
+
}
|
|
159
|
+
return $total
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
$cap = 20
|
|
163
|
+
if ($env:PWF_GATE_CAP -match '^\d+$') {
|
|
164
|
+
$cap = [int]$env:PWF_GATE_CAP
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
$blocksFile = Join-Path $PlanDir ".stop_blocks"
|
|
168
|
+
$blocks = 0
|
|
169
|
+
if (Test-Path $blocksFile) {
|
|
170
|
+
$raw = (Get-Content $blocksFile -Raw -ErrorAction SilentlyContinue)
|
|
171
|
+
if ($raw -match '^\s*(\d+)') { $blocks = [int]$Matches[1] }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
$ledgerFile = Join-Path $PlanDir ".gate_last_ledger"
|
|
175
|
+
$ledgerPrev = 0
|
|
176
|
+
if (Test-Path $ledgerFile) {
|
|
177
|
+
$raw = (Get-Content $ledgerFile -Raw -ErrorAction SilentlyContinue)
|
|
178
|
+
if ($raw -match '^\s*(\d+)') { $ledgerPrev = [int]$Matches[1] }
|
|
179
|
+
}
|
|
180
|
+
$ledgerNow = Get-LedgerLineCount
|
|
181
|
+
|
|
182
|
+
# Guard 4: block-count cap.
|
|
183
|
+
if ($blocks -ge $cap) {
|
|
184
|
+
Write-AdvisoryReport
|
|
185
|
+
Write-Host ('[planning-with-files] gate cap reached (' + $blocks + '/' + $cap + ') -- allowing stop.')
|
|
186
|
+
exit 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Guard 5: stall detection.
|
|
190
|
+
if ($blocks -gt 0 -and $ledgerNow -eq $ledgerPrev) {
|
|
191
|
+
Write-AdvisoryReport
|
|
192
|
+
Write-Host '[planning-with-files] no progress since last gate block -- allowing stop.'
|
|
193
|
+
exit 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# All guards passed: block the stop.
|
|
197
|
+
# Get-FirstInProgressPhase: heading text of the first phase whose Status is
|
|
198
|
+
# in_progress. Plain text only -- no plan body beyond the heading.
|
|
199
|
+
function Get-FirstInProgressPhase {
|
|
200
|
+
$heading = ""
|
|
201
|
+
foreach ($line in ($content -split "`n")) {
|
|
202
|
+
$trimmed = $line.TrimEnd("`r")
|
|
203
|
+
if ($trimmed -match '^### (.*)$') {
|
|
204
|
+
$heading = $Matches[1]
|
|
205
|
+
} elseif ($trimmed -match '\*\*Status:\*\* in_progress' -or $trimmed -match '\[in_progress\]') {
|
|
206
|
+
return $heading
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return ""
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
$phaseName = Get-FirstInProgressPhase
|
|
213
|
+
if ($phaseName -eq "") { $phaseName = "unknown phase" }
|
|
214
|
+
|
|
215
|
+
# JSON-escape: backslash and double-quote, plus every bare control character
|
|
216
|
+
# JSON forbids (below 0x20) mapped to a space. A phase heading may carry a
|
|
217
|
+
# literal tab; left raw it produces invalid JSON the Stop hook rejects. Same
|
|
218
|
+
# logic as ledger-append.ps1 ConvertTo-JsonString.
|
|
219
|
+
function ConvertTo-JsonEscaped {
|
|
220
|
+
param([string] $Value)
|
|
221
|
+
$sb = New-Object System.Text.StringBuilder
|
|
222
|
+
foreach ($ch in $Value.ToCharArray()) {
|
|
223
|
+
switch ($ch) {
|
|
224
|
+
'"' { [void]$sb.Append('\"') }
|
|
225
|
+
'\' { [void]$sb.Append('\\') }
|
|
226
|
+
default {
|
|
227
|
+
if ([int]$ch -lt 32) {
|
|
228
|
+
[void]$sb.Append(' ')
|
|
229
|
+
} else {
|
|
230
|
+
[void]$sb.Append($ch)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return $sb.ToString()
|
|
236
|
+
}
|
|
237
|
+
$phaseEscaped = ConvertTo-JsonEscaped $phaseName
|
|
238
|
+
|
|
239
|
+
$newBlocks = $blocks + 1
|
|
240
|
+
# Write sidecars as ASCII (single-byte digits) with an explicit LF and no BOM.
|
|
241
|
+
# Set-Content on Windows emits CRLF; check-complete.sh then reads '5\r', whose
|
|
242
|
+
# trailing CR makes the numeric guard reset BLOCKS to 0 on every cross-platform
|
|
243
|
+
# read, so the cap and stall guards never fire. WriteAllText with ASCII gives
|
|
244
|
+
# byte-for-byte '5\n' that both shells parse identically.
|
|
245
|
+
try { [System.IO.File]::WriteAllText($blocksFile, [string]$newBlocks + "`n", [System.Text.Encoding]::ASCII) } catch {}
|
|
246
|
+
try { [System.IO.File]::WriteAllText($ledgerFile, [string]$ledgerNow + "`n", [System.Text.Encoding]::ASCII) } catch {}
|
|
247
|
+
|
|
248
|
+
# Reason built from the JSON-escaped phase name; the surrounding template text
|
|
249
|
+
# has no quotes or backslashes, so only the heading needs escaping.
|
|
250
|
+
$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."
|
|
251
|
+
|
|
252
|
+
[Console]::Out.Write('{"decision":"block","reason":"' + $reason + '"}' + "`n")
|
|
253
|
+
exit 0
|