planning-with-files 3.9.0 → 3.10.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.
- package/README.md +22 -5
- package/SKILL.md +248 -24
- package/package.json +10 -3
- package/scripts/gate-stop.sh +32 -0
- package/scripts/inject-plan.sh +645 -0
- package/scripts/ledger-append.ps1 +180 -0
- package/scripts/ledger-append.sh +337 -0
- package/scripts/ledger-summary.ps1 +128 -0
- package/scripts/ledger-summary.sh +162 -0
- package/scripts/phase-status.ps1 +175 -0
- package/scripts/phase-status.sh +158 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# planning-with-files: emit a fixed-shape, cache-stable run-ledger summary (v3).
|
|
3
|
+
#
|
|
4
|
+
# This replaces raw `tail -20 progress.md` injection in autonomous mode. The
|
|
5
|
+
# output is synthesized from the machine ledger and task_plan.md status counts
|
|
6
|
+
# only: NO free text from disk reaches the model context, and there are NO
|
|
7
|
+
# timestamps, so the injected block is KV-cache stable by construction
|
|
8
|
+
# (architecture C3 injection rule).
|
|
9
|
+
#
|
|
10
|
+
# Plan-dir resolution:
|
|
11
|
+
# 0. Explicit plan-dir argument (issue #212, see below)
|
|
12
|
+
# 1. $PLAN_ID env var -> ./.planning/$PLAN_ID/ (via resolve-plan-dir.sh)
|
|
13
|
+
# 2. ./.planning/.active_plan (via resolve-plan-dir.sh)
|
|
14
|
+
# 3. Newest ./.planning/<dir>/ by mtime (via resolve-plan-dir.sh)
|
|
15
|
+
# 4. Legacy: project root
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# sh scripts/ledger-summary.sh [plan-dir]
|
|
19
|
+
#
|
|
20
|
+
# The optional argument is the caller's already-resolved plan directory and
|
|
21
|
+
# wins over self-resolution: inject-plan.sh passes the dir it resolved,
|
|
22
|
+
# because a cwd-based re-resolution here would pair a PWF_PLAN_ROOT-pinned
|
|
23
|
+
# plan's body with the PARENT project's phase counts and agent events — a
|
|
24
|
+
# false termination signal for an autonomous loop. No argument keeps the
|
|
25
|
+
# self-resolution above unchanged.
|
|
26
|
+
#
|
|
27
|
+
# Output block (stable shape):
|
|
28
|
+
# === RUN LEDGER ===
|
|
29
|
+
# entries: <N>
|
|
30
|
+
# phases: <complete>/<total> complete
|
|
31
|
+
# in_progress: <phase heading or none>
|
|
32
|
+
# agent <name>: <last event type>
|
|
33
|
+
# ...
|
|
34
|
+
# ==================
|
|
35
|
+
#
|
|
36
|
+
# When no plan directory is determinable at all (argument names a missing dir,
|
|
37
|
+
# or no argument AND resolve-plan-dir.sh is not next to this script), the block
|
|
38
|
+
# is replaced by a clearly marked unavailable state instead of a confident
|
|
39
|
+
# "phases: 0/0 complete" — see emit_unavailable below.
|
|
40
|
+
|
|
41
|
+
set -u
|
|
42
|
+
|
|
43
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
44
|
+
RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
|
|
45
|
+
ARG_DIR="${1:-}"
|
|
46
|
+
|
|
47
|
+
# Loud degradation: when the counts below are NOT computable — the caller
|
|
48
|
+
# named a plan dir that is gone, or no dir was passed and the resolver is
|
|
49
|
+
# missing next to this script — emit a clearly marked unavailable block
|
|
50
|
+
# instead of a confident "phases: 0/0 complete" + "in_progress: none", which
|
|
51
|
+
# an autonomous loop would read as its termination signal. Fixed strings
|
|
52
|
+
# only, so the block stays byte-stable (no timestamps, no free text from
|
|
53
|
+
# disk). Exit 0: this feeds hook output and must never error the agent loop.
|
|
54
|
+
emit_unavailable() {
|
|
55
|
+
printf '=== RUN LEDGER ===\n'
|
|
56
|
+
printf 'ledger: unavailable (%s)\n' "$1"
|
|
57
|
+
printf '==================\n'
|
|
58
|
+
exit 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
PLAN_DIR=""
|
|
62
|
+
if [ -n "${ARG_DIR}" ]; then
|
|
63
|
+
# The caller already resolved the plan dir; never second-guess it with a
|
|
64
|
+
# cwd-based re-resolution (that is exactly the parent/child mismatch this
|
|
65
|
+
# argument exists to prevent). If the named dir is gone, say so.
|
|
66
|
+
[ -d "${ARG_DIR}" ] || emit_unavailable "plan dir argument does not exist"
|
|
67
|
+
PLAN_DIR="${ARG_DIR}"
|
|
68
|
+
elif [ -f "${RESOLVER}" ]; then
|
|
69
|
+
PLAN_DIR="$(sh "${RESOLVER}" 2>/dev/null)"
|
|
70
|
+
if [ -z "${PLAN_DIR}" ] || [ ! -d "${PLAN_DIR}" ]; then
|
|
71
|
+
PLAN_DIR="."
|
|
72
|
+
fi
|
|
73
|
+
else
|
|
74
|
+
emit_unavailable "resolve-plan-dir.sh missing and no plan dir argument"
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
if [ "${PLAN_DIR}" = "." ]; then
|
|
78
|
+
PLAN_FILE="./task_plan.md"
|
|
79
|
+
else
|
|
80
|
+
PLAN_FILE="${PLAN_DIR}/task_plan.md"
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
# --- Phase counts: identical grep patterns to check-complete.sh ---
|
|
84
|
+
TOTAL=0
|
|
85
|
+
COMPLETE=0
|
|
86
|
+
IN_PROGRESS=0
|
|
87
|
+
IN_PROGRESS_HEADING="none"
|
|
88
|
+
if [ -f "${PLAN_FILE}" ]; then
|
|
89
|
+
TOTAL=$(grep -c "### Phase" "${PLAN_FILE}" 2>/dev/null || true)
|
|
90
|
+
COMPLETE=$(grep -cF "**Status:** complete" "${PLAN_FILE}" 2>/dev/null || true)
|
|
91
|
+
IN_PROGRESS=$(grep -cF "**Status:** in_progress" "${PLAN_FILE}" 2>/dev/null || true)
|
|
92
|
+
|
|
93
|
+
# Fallback to inline [status] format when **Status:** is absent.
|
|
94
|
+
if [ "${COMPLETE}" -eq 0 ] && [ "${IN_PROGRESS}" -eq 0 ]; then
|
|
95
|
+
c2=$(grep -c "\[complete\]" "${PLAN_FILE}" 2>/dev/null || true)
|
|
96
|
+
i2=$(grep -c "\[in_progress\]" "${PLAN_FILE}" 2>/dev/null || true)
|
|
97
|
+
: "${c2:=0}"
|
|
98
|
+
: "${i2:=0}"
|
|
99
|
+
if [ "${c2}" -gt 0 ] || [ "${i2}" -gt 0 ]; then
|
|
100
|
+
COMPLETE="${c2}"
|
|
101
|
+
IN_PROGRESS="${i2}"
|
|
102
|
+
fi
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# Heading of the FIRST phase whose status block is in_progress. We walk
|
|
106
|
+
# phase headings and look ahead for the status line so the summary names
|
|
107
|
+
# the active phase without leaking any plan body text beyond the heading.
|
|
108
|
+
heading=""
|
|
109
|
+
state=""
|
|
110
|
+
# shellcheck disable=SC2162
|
|
111
|
+
while IFS= read -r line; do
|
|
112
|
+
case "${line}" in
|
|
113
|
+
"### Phase"*)
|
|
114
|
+
heading="${line}"
|
|
115
|
+
;;
|
|
116
|
+
*"**Status:** in_progress"*)
|
|
117
|
+
if [ -n "${heading}" ]; then
|
|
118
|
+
IN_PROGRESS_HEADING="${heading}"
|
|
119
|
+
break
|
|
120
|
+
fi
|
|
121
|
+
;;
|
|
122
|
+
*"[in_progress]"*)
|
|
123
|
+
if [ -n "${heading}" ] && [ "${IN_PROGRESS_HEADING}" = "none" ]; then
|
|
124
|
+
IN_PROGRESS_HEADING="${heading}"
|
|
125
|
+
fi
|
|
126
|
+
;;
|
|
127
|
+
esac
|
|
128
|
+
done < "${PLAN_FILE}"
|
|
129
|
+
fi
|
|
130
|
+
: "${TOTAL:=0}"
|
|
131
|
+
: "${COMPLETE:=0}"
|
|
132
|
+
: "${IN_PROGRESS:=0}"
|
|
133
|
+
|
|
134
|
+
# --- Ledger stats: total entries + last event type per agent ---
|
|
135
|
+
TOTAL_ENTRIES=0
|
|
136
|
+
for f in "${PLAN_DIR}"/ledger-*.jsonl; do
|
|
137
|
+
[ -f "${f}" ] || continue
|
|
138
|
+
n=$(grep -c '"tick"' "${f}" 2>/dev/null || true)
|
|
139
|
+
: "${n:=0}"
|
|
140
|
+
TOTAL_ENTRIES=$((TOTAL_ENTRIES + n))
|
|
141
|
+
done
|
|
142
|
+
|
|
143
|
+
printf '=== RUN LEDGER ===\n'
|
|
144
|
+
printf 'entries: %s\n' "${TOTAL_ENTRIES}"
|
|
145
|
+
printf 'phases: %s/%s complete\n' "${COMPLETE}" "${TOTAL}"
|
|
146
|
+
printf 'in_progress: %s\n' "${IN_PROGRESS_HEADING}"
|
|
147
|
+
|
|
148
|
+
# Per-agent last event type. Agent name comes from the filename
|
|
149
|
+
# (ledger-<agent>.jsonl); the last event is parsed from the final line.
|
|
150
|
+
for f in "${PLAN_DIR}"/ledger-*.jsonl; do
|
|
151
|
+
[ -f "${f}" ] || continue
|
|
152
|
+
base="$(basename "${f}")"
|
|
153
|
+
agent="${base#ledger-}"
|
|
154
|
+
agent="${agent%.jsonl}"
|
|
155
|
+
last_line="$(tail -n 1 "${f}" 2>/dev/null)"
|
|
156
|
+
last_event="$(printf '%s' "${last_line}" | sed -n 's/.*"event"[[:space:]]*:[[:space:]]*"\([A-Za-z_]*\)".*/\1/p')"
|
|
157
|
+
[ -z "${last_event}" ] && last_event="none"
|
|
158
|
+
printf 'agent %s: %s\n' "${agent}" "${last_event}"
|
|
159
|
+
done
|
|
160
|
+
|
|
161
|
+
printf '==================\n'
|
|
162
|
+
exit 0
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#requires -Version 5.0
|
|
2
|
+
<#
|
|
3
|
+
.SYNOPSIS
|
|
4
|
+
Set the status of one phase in task_plan.md (PowerShell mirror, v3).
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
The ONLY sanctioned concurrent-safe writer of task_plan.md status lines. The
|
|
8
|
+
orchestrator owns task_plan.md; workers NEVER edit it directly. The edit is
|
|
9
|
+
a read-modify-write under an exclusive lock on the <plan-dir>\.write_lock
|
|
10
|
+
sentinel, with an atomic temp-file + move swap so a torn write can never
|
|
11
|
+
leave a half-rewritten plan on disk (architecture C4).
|
|
12
|
+
|
|
13
|
+
Editing task_plan.md changes its SHA, so the orchestrator must re-attest at
|
|
14
|
+
phase boundaries (see attest-plan.ps1).
|
|
15
|
+
|
|
16
|
+
Plan-dir resolution matches resolve-plan-dir.ps1:
|
|
17
|
+
1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
|
|
18
|
+
2. .\.planning\.active_plan
|
|
19
|
+
3. Newest .\.planning\<dir>\ by LastWriteTime
|
|
20
|
+
4. Legacy: project root .\task_plan.md
|
|
21
|
+
|
|
22
|
+
Exits 1 with a message if the phase does not exist or the status is invalid.
|
|
23
|
+
|
|
24
|
+
.PARAMETER Phase
|
|
25
|
+
Phase number (positive integer).
|
|
26
|
+
|
|
27
|
+
.PARAMETER Status
|
|
28
|
+
New status: pending, in_progress, or complete.
|
|
29
|
+
#>
|
|
30
|
+
[CmdletBinding()]
|
|
31
|
+
param(
|
|
32
|
+
[Parameter(Mandatory = $true, Position = 0)]
|
|
33
|
+
[string] $Phase,
|
|
34
|
+
|
|
35
|
+
[Parameter(Mandatory = $true, Position = 1)]
|
|
36
|
+
[string] $Status
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
$ErrorActionPreference = "Stop"
|
|
40
|
+
|
|
41
|
+
function Resolve-PlanFile {
|
|
42
|
+
$planRoot = Join-Path (Get-Location) ".planning"
|
|
43
|
+
|
|
44
|
+
if ($env:PLAN_ID) {
|
|
45
|
+
$candidate = Join-Path $planRoot $env:PLAN_ID
|
|
46
|
+
$planFile = Join-Path $candidate "task_plan.md"
|
|
47
|
+
if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
$activePointer = Join-Path $planRoot ".active_plan"
|
|
51
|
+
if (Test-Path -LiteralPath $activePointer) {
|
|
52
|
+
$planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
|
|
53
|
+
if ($planId) {
|
|
54
|
+
$candidate = Join-Path $planRoot $planId
|
|
55
|
+
$planFile = Join-Path $candidate "task_plan.md"
|
|
56
|
+
if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Test-Path -LiteralPath $planRoot -PathType Container) {
|
|
61
|
+
$newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
|
|
62
|
+
Where-Object { -not $_.Name.StartsWith(".") } |
|
|
63
|
+
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
|
|
64
|
+
Sort-Object LastWriteTime -Descending |
|
|
65
|
+
Select-Object -First 1
|
|
66
|
+
if ($newest) {
|
|
67
|
+
return (Resolve-Path -LiteralPath (Join-Path $newest.FullName "task_plan.md")).Path
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
$legacy = Join-Path (Get-Location) "task_plan.md"
|
|
72
|
+
if (Test-Path -LiteralPath $legacy) {
|
|
73
|
+
return (Resolve-Path -LiteralPath $legacy).Path
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return $null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Validate phase number is a positive integer.
|
|
80
|
+
if ($Phase -notmatch '^[0-9]+$') {
|
|
81
|
+
Write-Error ("[phase-status] phase number must be a positive integer, got '" + $Phase + "'.")
|
|
82
|
+
exit 1
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Validate status value against the allowlist.
|
|
86
|
+
$validStatus = @("pending", "in_progress", "complete")
|
|
87
|
+
if ($validStatus -notcontains $Status) {
|
|
88
|
+
Write-Error ("[phase-status] invalid status '" + $Status + "' (allowed: pending, in_progress, complete).")
|
|
89
|
+
exit 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$planFile = Resolve-PlanFile
|
|
93
|
+
if (-not $planFile) {
|
|
94
|
+
Write-Error "[phase-status] No task_plan.md found. Create a plan first."
|
|
95
|
+
exit 1
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$planDir = Split-Path -Parent $planFile
|
|
99
|
+
$lockFile = Join-Path $planDir ".write_lock"
|
|
100
|
+
|
|
101
|
+
# Acquire an exclusive lock on the sentinel so concurrent writers serialize.
|
|
102
|
+
$fs = $null
|
|
103
|
+
$acquired = $false
|
|
104
|
+
for ($i = 0; $i -lt 50 -and -not $acquired; $i++) {
|
|
105
|
+
try {
|
|
106
|
+
$fs = [System.IO.File]::Open($lockFile, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
|
107
|
+
$acquired = $true
|
|
108
|
+
} catch {
|
|
109
|
+
Start-Sleep -Milliseconds 100
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
$tmpFile = $planFile + ".tmp." + $PID
|
|
114
|
+
$rc = 0
|
|
115
|
+
try {
|
|
116
|
+
$lines = Get-Content -LiteralPath $planFile
|
|
117
|
+
|
|
118
|
+
# Confirm the phase heading exists.
|
|
119
|
+
$headingRe = '^### Phase ' + $Phase + '([^0-9]|$)'
|
|
120
|
+
if (-not ($lines | Where-Object { $_ -match $headingRe })) {
|
|
121
|
+
Write-Error ("[phase-status] Phase " + $Phase + " not found in " + $planFile + ".")
|
|
122
|
+
$rc = 1
|
|
123
|
+
} else {
|
|
124
|
+
$inBlock = $false
|
|
125
|
+
$done = $false
|
|
126
|
+
$out = New-Object System.Collections.Generic.List[string]
|
|
127
|
+
foreach ($line in $lines) {
|
|
128
|
+
$emit = $line
|
|
129
|
+
if ($line -match '^### Phase ') {
|
|
130
|
+
$rest = $line -replace '^### Phase ', ''
|
|
131
|
+
$num = $rest -replace '[^0-9].*$', ''
|
|
132
|
+
if (($num -eq $Phase) -and (-not $done)) {
|
|
133
|
+
$inBlock = $true
|
|
134
|
+
} else {
|
|
135
|
+
$inBlock = $false
|
|
136
|
+
}
|
|
137
|
+
} elseif ($inBlock -and (-not $done) -and ($line -match '\*\*Status:\*\*')) {
|
|
138
|
+
$prefix = $line -replace '\*\*Status:\*\*.*$', ''
|
|
139
|
+
$emit = $prefix + '**Status:** ' + $Status
|
|
140
|
+
$inBlock = $false
|
|
141
|
+
$done = $true
|
|
142
|
+
}
|
|
143
|
+
$out.Add($emit)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (-not $done) {
|
|
147
|
+
Write-Error ("[phase-status] No **Status:** line found for Phase " + $Phase + ".")
|
|
148
|
+
$rc = 1
|
|
149
|
+
} else {
|
|
150
|
+
# Atomic-enough swap: write temp, then move over the target.
|
|
151
|
+
# Write BOM-less UTF-8 (platform-major): Set-Content -Encoding utf8 on
|
|
152
|
+
# Windows PowerShell 5.1 prepends a UTF-8 BOM (EF BB BF). The temp file
|
|
153
|
+
# then replaces task_plan.md, so every phase-status call from PS 5.1
|
|
154
|
+
# changes the file's leading bytes. If the plan was created on Linux or
|
|
155
|
+
# macOS (no BOM), the stored attestation SHA-256 no longer matches and
|
|
156
|
+
# inject-plan.sh blocks all further injection as [PLAN TAMPERED]. A
|
|
157
|
+
# UTF8Encoding constructed with $false emits no BOM on every PS version.
|
|
158
|
+
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
159
|
+
[System.IO.File]::WriteAllLines($tmpFile, $out, $utf8NoBom)
|
|
160
|
+
Move-Item -LiteralPath $tmpFile -Destination $planFile -Force
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
Write-Error ("[phase-status] " + $_.Exception.Message)
|
|
165
|
+
$rc = 1
|
|
166
|
+
} finally {
|
|
167
|
+
if ($fs) { $fs.Close(); $fs.Dispose() }
|
|
168
|
+
if (Test-Path -LiteralPath $lockFile) { Remove-Item -LiteralPath $lockFile -Force -ErrorAction SilentlyContinue }
|
|
169
|
+
if (Test-Path -LiteralPath $tmpFile) { Remove-Item -LiteralPath $tmpFile -Force -ErrorAction SilentlyContinue }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if ($rc -ne 0) { exit 1 }
|
|
173
|
+
|
|
174
|
+
Write-Output ("[phase-status] Phase " + $Phase + " -> " + $Status + " in " + $planFile)
|
|
175
|
+
exit 0
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# planning-with-files: set the status of one phase in task_plan.md (v3).
|
|
3
|
+
#
|
|
4
|
+
# This is the ONLY sanctioned concurrent-safe writer of task_plan.md status
|
|
5
|
+
# lines. The orchestrator owns task_plan.md; workers NEVER edit it directly.
|
|
6
|
+
# All status edits go through this read-modify-write under an advisory flock on
|
|
7
|
+
# the <plan-dir>/.write_lock sentinel, with an atomic temp-file + mv swap so a
|
|
8
|
+
# torn write can never leave a half-rewritten plan on disk (architecture C4).
|
|
9
|
+
#
|
|
10
|
+
# Note: editing task_plan.md changes its SHA, so the orchestrator must
|
|
11
|
+
# re-attest at phase boundaries (see attest-plan.sh).
|
|
12
|
+
#
|
|
13
|
+
# Plan-dir resolution (via resolve-plan-dir.sh):
|
|
14
|
+
# 1. $PLAN_ID env var -> ./.planning/$PLAN_ID/
|
|
15
|
+
# 2. ./.planning/.active_plan
|
|
16
|
+
# 3. Newest ./.planning/<dir>/ by mtime
|
|
17
|
+
# 4. Legacy: project root ./task_plan.md
|
|
18
|
+
#
|
|
19
|
+
# Usage:
|
|
20
|
+
# sh scripts/phase-status.sh <phase-number> <pending|in_progress|complete>
|
|
21
|
+
#
|
|
22
|
+
# Exits 1 with a message if the phase does not exist or the status is invalid.
|
|
23
|
+
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
27
|
+
RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
|
|
28
|
+
|
|
29
|
+
usage() {
|
|
30
|
+
printf "Usage: %s <phase-number> <pending|in_progress|complete>\n" "$0" >&2
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
resolve_plan_file() {
|
|
34
|
+
plan_dir=""
|
|
35
|
+
if [ -f "${RESOLVER}" ]; then
|
|
36
|
+
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
|
|
37
|
+
fi
|
|
38
|
+
if [ -n "${plan_dir}" ] && [ -f "${plan_dir}/task_plan.md" ]; then
|
|
39
|
+
printf "%s\n" "${plan_dir}/task_plan.md"
|
|
40
|
+
return 0
|
|
41
|
+
fi
|
|
42
|
+
if [ -f "./task_plan.md" ]; then
|
|
43
|
+
printf "%s\n" "./task_plan.md"
|
|
44
|
+
return 0
|
|
45
|
+
fi
|
|
46
|
+
return 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
PHASE_NUM="${1:-}"
|
|
50
|
+
NEW_STATUS="${2:-}"
|
|
51
|
+
|
|
52
|
+
if [ -z "${PHASE_NUM}" ] || [ -z "${NEW_STATUS}" ]; then
|
|
53
|
+
usage
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# Validate phase number is a positive integer.
|
|
58
|
+
case "${PHASE_NUM}" in
|
|
59
|
+
''|*[!0-9]*)
|
|
60
|
+
printf "[phase-status] phase number must be a positive integer, got '%s'.\n" "${PHASE_NUM}" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
;;
|
|
63
|
+
esac
|
|
64
|
+
|
|
65
|
+
# Validate status value against the allowlist.
|
|
66
|
+
case "${NEW_STATUS}" in
|
|
67
|
+
pending|in_progress|complete) : ;;
|
|
68
|
+
*)
|
|
69
|
+
printf "[phase-status] invalid status '%s' (allowed: pending, in_progress, complete).\n" "${NEW_STATUS}" >&2
|
|
70
|
+
exit 1
|
|
71
|
+
;;
|
|
72
|
+
esac
|
|
73
|
+
|
|
74
|
+
PLAN_FILE="$(resolve_plan_file)" || {
|
|
75
|
+
printf "[phase-status] No task_plan.md found. Create a plan first.\n" >&2
|
|
76
|
+
exit 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
PLAN_DIR="$(dirname "${PLAN_FILE}")"
|
|
80
|
+
LOCK_FILE="${PLAN_DIR}/.write_lock"
|
|
81
|
+
|
|
82
|
+
# Confirm the phase heading exists before touching the file.
|
|
83
|
+
if ! grep -q "### Phase ${PHASE_NUM}\b" "${PLAN_FILE}" 2>/dev/null; then
|
|
84
|
+
# Fall back to a looser match for headings like "### Phase 1:" where \b may
|
|
85
|
+
# not be honored by a minimal grep.
|
|
86
|
+
if ! grep -Eq "^### Phase ${PHASE_NUM}([^0-9]|$)" "${PLAN_FILE}" 2>/dev/null; then
|
|
87
|
+
printf "[phase-status] Phase %s not found in %s.\n" "${PHASE_NUM}" "${PLAN_FILE}" >&2
|
|
88
|
+
exit 1
|
|
89
|
+
fi
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# Rewrite only the FIRST "**Status:**" line that follows the "### Phase N"
|
|
93
|
+
# heading. awk tracks whether we are inside the target phase block; once we
|
|
94
|
+
# rewrite its status line we stop matching so later phases are untouched.
|
|
95
|
+
rewrite() {
|
|
96
|
+
src="$1"
|
|
97
|
+
dst="$2"
|
|
98
|
+
awk -v target="${PHASE_NUM}" -v newstatus="${NEW_STATUS}" '
|
|
99
|
+
BEGIN { in_block = 0; done = 0 }
|
|
100
|
+
{
|
|
101
|
+
line = $0
|
|
102
|
+
if (line ~ /^### Phase /) {
|
|
103
|
+
# Extract the phase number right after "### Phase ".
|
|
104
|
+
rest = line
|
|
105
|
+
sub(/^### Phase /, "", rest)
|
|
106
|
+
num = rest
|
|
107
|
+
sub(/[^0-9].*$/, "", num)
|
|
108
|
+
if (num == target && done == 0) {
|
|
109
|
+
in_block = 1
|
|
110
|
+
} else {
|
|
111
|
+
in_block = 0
|
|
112
|
+
}
|
|
113
|
+
} else if (in_block == 1 && done == 0 && line ~ /\*\*Status:\*\*/) {
|
|
114
|
+
# Preserve leading whitespace/bullet before "**Status:**".
|
|
115
|
+
prefix = line
|
|
116
|
+
sub(/\*\*Status:\*\*.*$/, "", prefix)
|
|
117
|
+
line = prefix "**Status:** " newstatus
|
|
118
|
+
in_block = 0
|
|
119
|
+
done = 1
|
|
120
|
+
}
|
|
121
|
+
print line
|
|
122
|
+
}
|
|
123
|
+
END { if (done == 0) exit 3 }
|
|
124
|
+
' "${src}" > "${dst}"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
TMP_FILE="${PLAN_FILE}.tmp.$$"
|
|
128
|
+
|
|
129
|
+
do_write() {
|
|
130
|
+
if ! rewrite "${PLAN_FILE}" "${TMP_FILE}"; then
|
|
131
|
+
rm -f "${TMP_FILE}" 2>/dev/null
|
|
132
|
+
printf "[phase-status] No **Status:** line found for Phase %s.\n" "${PHASE_NUM}" >&2
|
|
133
|
+
return 1
|
|
134
|
+
fi
|
|
135
|
+
mv -f "${TMP_FILE}" "${PLAN_FILE}"
|
|
136
|
+
return 0
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
rc=0
|
|
140
|
+
if command -v flock >/dev/null 2>&1; then
|
|
141
|
+
(
|
|
142
|
+
flock -w 5 9 || true
|
|
143
|
+
do_write
|
|
144
|
+
) 9>"${LOCK_FILE}" 2>/dev/null
|
|
145
|
+
rc=$?
|
|
146
|
+
rm -f "${LOCK_FILE}" 2>/dev/null || true
|
|
147
|
+
else
|
|
148
|
+
do_write
|
|
149
|
+
rc=$?
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
if [ "${rc}" -ne 0 ]; then
|
|
153
|
+
rm -f "${TMP_FILE}" 2>/dev/null
|
|
154
|
+
exit 1
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
printf "[phase-status] Phase %s -> %s in %s\n" "${PHASE_NUM}" "${NEW_STATUS}" "${PLAN_FILE}"
|
|
158
|
+
exit 0
|