planning-with-files 3.9.0

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.
Files changed (32) hide show
  1. package/README.md +131 -0
  2. package/SKILL.md +262 -0
  3. package/examples.md +202 -0
  4. package/extensions/planning-with-files/README.md +35 -0
  5. package/extensions/planning-with-files/__tests__/attestation.test.ts +79 -0
  6. package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -0
  7. package/extensions/planning-with-files/__tests__/runtime.test.ts +688 -0
  8. package/extensions/planning-with-files/attestation.ts +55 -0
  9. package/extensions/planning-with-files/constants.ts +31 -0
  10. package/extensions/planning-with-files/index.ts +6 -0
  11. package/extensions/planning-with-files/package.json +17 -0
  12. package/extensions/planning-with-files/plan.ts +263 -0
  13. package/extensions/planning-with-files/runtime.ts +788 -0
  14. package/package.json +46 -0
  15. package/reference.md +218 -0
  16. package/scripts/attest-plan.ps1 +137 -0
  17. package/scripts/attest-plan.sh +206 -0
  18. package/scripts/check-complete.ps1 +253 -0
  19. package/scripts/check-complete.sh +253 -0
  20. package/scripts/init-session.ps1 +230 -0
  21. package/scripts/init-session.sh +370 -0
  22. package/scripts/plan-doctor.sh +148 -0
  23. package/scripts/resolve-plan-dir.ps1 +106 -0
  24. package/scripts/resolve-plan-dir.sh +263 -0
  25. package/scripts/session-catchup.py +876 -0
  26. package/scripts/set-active-plan.ps1 +51 -0
  27. package/scripts/set-active-plan.sh +50 -0
  28. package/templates/analytics_findings.md +85 -0
  29. package/templates/analytics_task_plan.md +106 -0
  30. package/templates/findings.md +95 -0
  31. package/templates/progress.md +114 -0
  32. package/templates/task_plan.md +140 -0
@@ -0,0 +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
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env bash
2
+ # Check if all phases in task_plan.md are complete
3
+ # Default invocation: advisory echo, always exits 0 (Stop hook status report).
4
+ # With --gate: deliberate completion gate, opt-in per plan via <plan-dir>/.mode.
5
+ # Used by Stop hook to report task completion status.
6
+ #
7
+ # Plan-file resolution (v2.40+):
8
+ # 1. $1 (explicit path) — first non-flag positional argument
9
+ # 2. resolve-plan-dir.sh: $PLAN_ID env → .planning/.active_plan → newest mtime
10
+ # 3. Legacy ./task_plan.md
11
+ #
12
+ # This restores slug-mode parity: the Stop hook and any caller invoking with
13
+ # zero args now respects the active plan dir instead of silently defaulting to
14
+ # the legacy root path.
15
+ #
16
+ # Gate mode (v3, --gate flag):
17
+ # The gate is OFF unless ALL of these hold (design "Gate decision table"):
18
+ # 1. <plan-dir>/.mode exists and contains "gate" (explicit opt-in)
19
+ # 2. an in_progress phase exists (not merely complete<total)
20
+ # 3. the Stop hook input JSON on stdin does not set stop_hook_active=true
21
+ # 4. the block counter (<plan-dir>/.stop_blocks) is below cap (PWF_GATE_CAP, default 20)
22
+ # 5. the ledger advanced since the last block (stall → allow stop)
23
+ # When all hold, it emits a single-line block-decision JSON on stdout and
24
+ # exits 0. Otherwise it falls back to advisory output and exits 0.
25
+ # Without --gate, or in non-gated mode, behavior is byte-equivalent to v2.43.
26
+ #
27
+ # Stdin handling: the Claude Code Stop hook pipes a JSON payload on stdin. To
28
+ # avoid hanging when nothing is piped, stdin is read ONLY when fd 0 is not a
29
+ # TTY ([ -t 0 ]). Hook-piped input is EOF-terminated, so the read returns; an
30
+ # interactive terminal (TTY) is skipped entirely. No data on stdin is treated
31
+ # as stop_hook_active=false.
32
+
33
+ # issue #195: per-invocation opt-out (PLANNING_DISABLED=1) for one-shot/CI
34
+ # sessions that share a cwd with a plan but never opted into it.
35
+ [ "${PLANNING_DISABLED:-}" = "1" ] && exit 0
36
+
37
+ GATE=0
38
+ PLAN_FILE=""
39
+ for _arg in "$@"; do
40
+ case "$_arg" in
41
+ --gate) GATE=1 ;;
42
+ *)
43
+ if [ -z "$PLAN_FILE" ]; then
44
+ PLAN_FILE="$_arg"
45
+ fi
46
+ ;;
47
+ esac
48
+ done
49
+
50
+ PLAN_DIR=""
51
+ if [ -n "${PLAN_FILE}" ]; then
52
+ PLAN_DIR="$(dirname "${PLAN_FILE}")"
53
+ else
54
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR="."
55
+ RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
56
+ RESOLVED_DIR=""
57
+ if [ -f "${RESOLVER}" ]; then
58
+ RESOLVED_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
59
+ fi
60
+ if [ -n "${RESOLVED_DIR}" ] && [ -f "${RESOLVED_DIR}/task_plan.md" ]; then
61
+ PLAN_FILE="${RESOLVED_DIR}/task_plan.md"
62
+ PLAN_DIR="${RESOLVED_DIR}"
63
+ else
64
+ PLAN_FILE="task_plan.md"
65
+ PLAN_DIR="."
66
+ fi
67
+ fi
68
+
69
+ if [ ! -f "$PLAN_FILE" ]; then
70
+ echo "[planning-with-files] No task_plan.md found — no active planning session."
71
+ exit 0
72
+ fi
73
+
74
+ # Count total phases
75
+ TOTAL=$(grep -c "### Phase" "$PLAN_FILE" || true)
76
+
77
+ # Count both formats per field and keep the larger of the two. A plan may mix
78
+ # '**Status:** pending' on one phase with '[in_progress]' on another; counting
79
+ # only the primary format (and falling back to inline ONLY when all three
80
+ # primaries are zero) lost the inline count and let an in_progress plan slip
81
+ # past the gate. Per-field max preserves the legacy single-format result
82
+ # (the other format contributes 0) while catching mixed plans.
83
+ COMPLETE_PRIMARY=$(grep -cF "**Status:** complete" "$PLAN_FILE" || true)
84
+ IN_PROGRESS_PRIMARY=$(grep -cF "**Status:** in_progress" "$PLAN_FILE" || true)
85
+ PENDING_PRIMARY=$(grep -cF "**Status:** pending" "$PLAN_FILE" || true)
86
+
87
+ COMPLETE_INLINE=$(grep -c "\[complete\]" "$PLAN_FILE" || true)
88
+ IN_PROGRESS_INLINE=$(grep -c "\[in_progress\]" "$PLAN_FILE" || true)
89
+ PENDING_INLINE=$(grep -c "\[pending\]" "$PLAN_FILE" || true)
90
+
91
+ : "${COMPLETE_PRIMARY:=0}"; : "${IN_PROGRESS_PRIMARY:=0}"; : "${PENDING_PRIMARY:=0}"
92
+ : "${COMPLETE_INLINE:=0}"; : "${IN_PROGRESS_INLINE:=0}"; : "${PENDING_INLINE:=0}"
93
+
94
+ if [ "$COMPLETE_INLINE" -gt "$COMPLETE_PRIMARY" ]; then COMPLETE="$COMPLETE_INLINE"; else COMPLETE="$COMPLETE_PRIMARY"; fi
95
+ if [ "$IN_PROGRESS_INLINE" -gt "$IN_PROGRESS_PRIMARY" ]; then IN_PROGRESS="$IN_PROGRESS_INLINE"; else IN_PROGRESS="$IN_PROGRESS_PRIMARY"; fi
96
+ if [ "$PENDING_INLINE" -gt "$PENDING_PRIMARY" ]; then PENDING="$PENDING_INLINE"; else PENDING="$PENDING_PRIMARY"; fi
97
+
98
+ # Default to 0 if empty
99
+ : "${TOTAL:=0}"
100
+ : "${COMPLETE:=0}"
101
+ : "${IN_PROGRESS:=0}"
102
+ : "${PENDING:=0}"
103
+
104
+ # issue #191: no "### Phase" headings -> not a phase-structured plan. Report
105
+ # nothing rather than a false "0/0 phases complete" status. With TOTAL=0 the
106
+ # gate can never legitimately block (IN_PROGRESS is also 0), so exit is safe.
107
+ if [ "$TOTAL" -eq 0 ]; then
108
+ exit 0
109
+ fi
110
+
111
+ # advisory_report: the v2.43 status echo. Always exit 0 after calling.
112
+ advisory_report() {
113
+ if [ "$COMPLETE" -eq "$TOTAL" ] && [ "$TOTAL" -gt 0 ]; then
114
+ echo "[planning-with-files] ALL PHASES COMPLETE ($COMPLETE/$TOTAL). If the user has additional work, add new phases to task_plan.md before starting."
115
+ else
116
+ echo "[planning-with-files] Task in progress ($COMPLETE/$TOTAL phases complete). Update progress.md before stopping."
117
+ if [ "$IN_PROGRESS" -gt 0 ]; then
118
+ echo "[planning-with-files] $IN_PROGRESS phase(s) still in progress."
119
+ fi
120
+ if [ "$PENDING" -gt 0 ]; then
121
+ echo "[planning-with-files] $PENDING phase(s) pending."
122
+ fi
123
+ fi
124
+ }
125
+
126
+ # ---- Default (advisory) path: byte-equivalent to v2.43 ----
127
+ if [ "$GATE" -ne 1 ]; then
128
+ advisory_report
129
+ exit 0
130
+ fi
131
+
132
+ # ---- Gate path (--gate). Resolves to advisory unless every guard says block. ----
133
+
134
+ # Guard 1: gated mode. The .mode file must contain "gate". Absent or other
135
+ # content means advisory mode (legacy behavior preserved).
136
+ MODE_FILE="${PLAN_DIR}/.mode"
137
+ if [ ! -f "${MODE_FILE}" ] || ! grep -q "gate" "${MODE_FILE}" 2>/dev/null; then
138
+ advisory_report
139
+ exit 0
140
+ fi
141
+
142
+ # Guard 3: stop_hook_active. Read the Stop hook JSON from stdin only when fd 0
143
+ # is not a TTY (see header). A true value means we are already inside a forced
144
+ # continuation; allow the stop to avoid runaway recursion.
145
+ STDIN_JSON=""
146
+ if [ ! -t 0 ]; then
147
+ STDIN_JSON="$(cat 2>/dev/null)"
148
+ fi
149
+ # Anchor on the VALUE: "stop_hook_active" immediately followed (allowing
150
+ # whitespace and the colon) by true. A bare glob like *stop_hook_active*true*
151
+ # false-positives on '{"stop_hook_active": false, "other": true}', which would
152
+ # silently disable the gate. Newlines are collapsed so the match works whether
153
+ # the payload is pretty-printed or single-line.
154
+ STOP_HOOK_ACTIVE="$(
155
+ printf '%s' "${STDIN_JSON}" \
156
+ | tr '\n' ' ' \
157
+ | sed -n 's/.*"stop_hook_active"[[:space:]]*:[[:space:]]*true.*/FOUND/p'
158
+ )"
159
+ if [ "${STOP_HOOK_ACTIVE}" = "FOUND" ]; then
160
+ advisory_report
161
+ exit 0
162
+ fi
163
+
164
+ # Guard 2: an in_progress phase must exist. Merely complete<total is a normal
165
+ # state and must NOT block (issue #178 lesson).
166
+ if [ "$IN_PROGRESS" -le 0 ]; then
167
+ advisory_report
168
+ exit 0
169
+ fi
170
+
171
+ # ledger_line_count: total lines across all <plan-dir>/ledger-*.jsonl files.
172
+ # Echoes a single integer (0 when no ledger files exist).
173
+ ledger_line_count() {
174
+ _total=0
175
+ for _lf in "${PLAN_DIR}"/ledger-*.jsonl; do
176
+ [ -f "${_lf}" ] || continue
177
+ _n="$(grep -c '' "${_lf}" 2>/dev/null || echo 0)"
178
+ _total=$((_total + _n))
179
+ done
180
+ printf "%s" "${_total}"
181
+ }
182
+
183
+ CAP="${PWF_GATE_CAP:-20}"
184
+ case "${CAP}" in
185
+ ''|*[!0-9]*) CAP=20 ;;
186
+ esac
187
+
188
+ BLOCKS_FILE="${PLAN_DIR}/.stop_blocks"
189
+ BLOCKS="$(cat "${BLOCKS_FILE}" 2>/dev/null || echo 0)"
190
+ case "${BLOCKS}" in
191
+ ''|*[!0-9]*) BLOCKS=0 ;;
192
+ esac
193
+
194
+ LEDGER_FILE="${PLAN_DIR}/.gate_last_ledger"
195
+ LEDGER_PREV="$(cat "${LEDGER_FILE}" 2>/dev/null || echo 0)"
196
+ case "${LEDGER_PREV}" in
197
+ ''|*[!0-9]*) LEDGER_PREV=0 ;;
198
+ esac
199
+ LEDGER_NOW="$(ledger_line_count)"
200
+
201
+ # Guard 4: block-count cap. At or over the cap, allow the stop.
202
+ if [ "${BLOCKS}" -ge "${CAP}" ]; then
203
+ advisory_report
204
+ echo "[planning-with-files] gate cap reached ($BLOCKS/$CAP) — allowing stop."
205
+ exit 0
206
+ fi
207
+
208
+ # Guard 5: stall detection. If we have blocked before (BLOCKS > 0) and the
209
+ # ledger line count has not advanced since the last block, nothing progressed:
210
+ # allow the stop instead of looping.
211
+ if [ "${BLOCKS}" -gt 0 ] && [ "${LEDGER_NOW}" -eq "${LEDGER_PREV}" ]; then
212
+ advisory_report
213
+ echo "[planning-with-files] no progress since last gate block — allowing stop."
214
+ exit 0
215
+ fi
216
+
217
+ # All guards passed: block the stop.
218
+ # json_escape: escape a string for safe inclusion in a JSON string literal.
219
+ # Escapes backslash and double-quote, then neutralizes every bare control
220
+ # character JSON forbids (0x01-0x1F) by mapping it to a space. A phase heading
221
+ # may carry a literal tab or other control byte; left raw it produces invalid
222
+ # JSON ("Bad control character in string literal") that the Stop hook rejects.
223
+ json_escape() {
224
+ printf "%s" "$1" \
225
+ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
226
+ | tr '\001-\037' ' '
227
+ }
228
+
229
+ # first_in_progress_phase: heading text of the first phase whose Status is
230
+ # in_progress. Reads the plan top-to-bottom, remembers the most recent
231
+ # "### " heading, and prints it (with the "### " prefix stripped) at the first
232
+ # in_progress status line. Plain text only — no plan body beyond the heading.
233
+ first_in_progress_phase() {
234
+ awk '
235
+ /^### / { heading = substr($0, 5); next }
236
+ /\*\*Status:\*\* in_progress/ { print heading; exit }
237
+ /\[in_progress\]/ { print heading; exit }
238
+ ' "$PLAN_FILE"
239
+ }
240
+
241
+ PHASE_NAME="$(first_in_progress_phase)"
242
+ if [ -z "${PHASE_NAME}" ]; then
243
+ PHASE_NAME="unknown phase"
244
+ fi
245
+ PHASE_ESCAPED="$(json_escape "${PHASE_NAME}")"
246
+
247
+ NEW_BLOCKS=$((BLOCKS + 1))
248
+ printf "%s\n" "${NEW_BLOCKS}" > "${BLOCKS_FILE}" 2>/dev/null || true
249
+ printf "%s\n" "${LEDGER_NOW}" > "${LEDGER_FILE}" 2>/dev/null || true
250
+
251
+ printf '{"decision":"block","reason":"[planning-with-files] Gated plan incomplete: phase '\''%s'\'' is in_progress (%s/%s complete, gate block %s/%s). Finish or update the plan, then stop."}\n' \
252
+ "${PHASE_ESCAPED}" "${COMPLETE}" "${TOTAL}" "${NEW_BLOCKS}" "${CAP}"
253
+ exit 0