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.
@@ -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