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,180 @@
1
+ #requires -Version 5.0
2
+ <#
3
+ .SYNOPSIS
4
+ Append one structured entry to the run-ledger (PowerShell mirror, v3).
5
+
6
+ .DESCRIPTION
7
+ The run-ledger is the machine layer of progress tracking: an append-only
8
+ JSON-lines file per agent under the active plan dir. Workers append here;
9
+ the orchestrator owns progress.md and task_plan.md. See architecture C3.
10
+
11
+ Plan-dir resolution (matches resolve-plan-dir.ps1):
12
+ 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
13
+ 2. .\.planning\.active_plan
14
+ 3. Newest .\.planning\<dir>\ by LastWriteTime
15
+ 4. Legacy: project root (ledger lands beside .\task_plan.md)
16
+
17
+ Writes ONE JSON line to <plan-dir>\ledger-<agent>.jsonl. tick = 1 + max tick
18
+ across ALL ledger-*.jsonl in the plan dir so concurrent agents share a
19
+ monotonic counter.
20
+
21
+ .PARAMETER Event
22
+ One of: progress phase_complete error gate_block attest note.
23
+
24
+ .PARAMETER Summary
25
+ Free text, truncated to 200 chars, newlines stripped.
26
+
27
+ .PARAMETER Agent
28
+ Ledger owner (default "main"); sanitized to [A-Za-z0-9_-].
29
+
30
+ .PARAMETER Phase
31
+ Phase number/name this entry concerns.
32
+
33
+ .PARAMETER Files
34
+ Comma-separated file list recorded as a JSON array.
35
+ #>
36
+ [CmdletBinding()]
37
+ param(
38
+ [Parameter(Mandatory = $true, Position = 0)]
39
+ [string] $Event,
40
+
41
+ [Parameter(Mandatory = $true, Position = 1)]
42
+ [string] $Summary,
43
+
44
+ [string] $Agent = "main",
45
+
46
+ [string] $Phase = "",
47
+
48
+ [string] $Files = ""
49
+ )
50
+
51
+ $ErrorActionPreference = "Stop"
52
+
53
+ $validEvents = @("progress", "phase_complete", "error", "gate_block", "attest", "note")
54
+
55
+ function Resolve-PlanDir {
56
+ $planRoot = Join-Path (Get-Location) ".planning"
57
+
58
+ if ($env:PLAN_ID) {
59
+ $candidate = Join-Path $planRoot $env:PLAN_ID
60
+ if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
61
+ }
62
+
63
+ $activePointer = Join-Path $planRoot ".active_plan"
64
+ if (Test-Path -LiteralPath $activePointer) {
65
+ $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
66
+ if ($planId) {
67
+ $candidate = Join-Path $planRoot $planId
68
+ if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
69
+ }
70
+ }
71
+
72
+ if (Test-Path -LiteralPath $planRoot -PathType Container) {
73
+ $newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
74
+ Where-Object { -not $_.Name.StartsWith(".") } |
75
+ Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
76
+ Sort-Object LastWriteTime -Descending |
77
+ Select-Object -First 1
78
+ if ($newest) { return $newest.FullName }
79
+ }
80
+
81
+ # Legacy single-file mode: ledger lives beside .\task_plan.md at root.
82
+ return (Get-Location).Path
83
+ }
84
+
85
+ function ConvertTo-JsonString {
86
+ param([string] $Value)
87
+ $sb = New-Object System.Text.StringBuilder
88
+ foreach ($ch in $Value.ToCharArray()) {
89
+ switch ($ch) {
90
+ '"' { [void]$sb.Append('\"') }
91
+ '\' { [void]$sb.Append('\\') }
92
+ "`n" { [void]$sb.Append(' ') }
93
+ "`r" { [void]$sb.Append(' ') }
94
+ "`t" { [void]$sb.Append(' ') }
95
+ default {
96
+ if ([int]$ch -lt 32) {
97
+ [void]$sb.Append(' ')
98
+ } else {
99
+ [void]$sb.Append($ch)
100
+ }
101
+ }
102
+ }
103
+ }
104
+ return $sb.ToString()
105
+ }
106
+
107
+ function Get-MaxTick {
108
+ param([string] $Dir)
109
+ $max = 0
110
+ $pattern = '"tick"\s*:\s*(\d+)'
111
+ Get-ChildItem -LiteralPath $Dir -Filter "ledger-*.jsonl" -File -ErrorAction SilentlyContinue | ForEach-Object {
112
+ foreach ($line in (Get-Content -LiteralPath $_.FullName -ErrorAction SilentlyContinue)) {
113
+ $m = [regex]::Match($line, $pattern)
114
+ if ($m.Success) {
115
+ $t = [int]$m.Groups[1].Value
116
+ if ($t -gt $max) { $max = $t }
117
+ }
118
+ }
119
+ }
120
+ return $max
121
+ }
122
+
123
+ # Validate event against the allowlist.
124
+ if ($validEvents -notcontains $Event) {
125
+ Write-Error ("[ledger] invalid event '" + $Event + "' (allowed: " + ($validEvents -join ' ') + ")")
126
+ exit 2
127
+ }
128
+
129
+ # Sanitize agent name to [A-Za-z0-9_-]; empty result falls back to "main".
130
+ $agentClean = ($Agent -replace '[^A-Za-z0-9_-]', '')
131
+ if (-not $agentClean) { $agentClean = "main" }
132
+
133
+ # Truncate summary to the 200-character budget before escaping, matching the
134
+ # sh twin. .NET Substring counts characters, never bytes, so multibyte input
135
+ # cannot be clipped mid-codepoint here and no UTF-8 tail repair is needed.
136
+ if ($Summary.Length -gt 200) { $Summary = $Summary.Substring(0, 200) }
137
+
138
+ $planDir = Resolve-PlanDir
139
+ $ledgerFile = Join-Path $planDir ("ledger-" + $agentClean + ".jsonl")
140
+ $lockFile = Join-Path $planDir ".ledger_lock"
141
+
142
+ $ts = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
143
+
144
+ # Build the files JSON array from the comma-separated list.
145
+ $filesJson = "[]"
146
+ if ($Files) {
147
+ $parts = $Files.Split(",") | Where-Object { $_ -ne "" }
148
+ $escaped = $parts | ForEach-Object { '"' + (ConvertTo-JsonString $_) + '"' }
149
+ $filesJson = "[" + ($escaped -join ",") + "]"
150
+ }
151
+
152
+ $summaryEsc = ConvertTo-JsonString $Summary
153
+ $phaseEsc = ConvertTo-JsonString $Phase
154
+
155
+ # Acquire an exclusive lock on a sidecar so concurrent appenders do not pick
156
+ # the same tick number, then compute tick and append inside the locked window.
157
+ # Atomic append of a single <4KB line is the real guarantee; the lock just
158
+ # serializes the read-tick / write-line pair.
159
+ $fs = $null
160
+ $acquired = $false
161
+ for ($i = 0; $i -lt 50 -and -not $acquired; $i++) {
162
+ try {
163
+ $fs = [System.IO.File]::Open($lockFile, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
164
+ $acquired = $true
165
+ } catch {
166
+ Start-Sleep -Milliseconds 100
167
+ }
168
+ }
169
+
170
+ try {
171
+ $tick = (Get-MaxTick $planDir) + 1
172
+ $line = '{"tick":' + $tick + ',"ts":"' + $ts + '","agent":"' + $agentClean + '","phase":"' + $phaseEsc + '","event":"' + $Event + '","summary":"' + $summaryEsc + '","files":' + $filesJson + '}'
173
+ Add-Content -LiteralPath $ledgerFile -Value $line -Encoding utf8
174
+ } finally {
175
+ if ($fs) { $fs.Close(); $fs.Dispose() }
176
+ if (Test-Path -LiteralPath $lockFile) { Remove-Item -LiteralPath $lockFile -Force -ErrorAction SilentlyContinue }
177
+ }
178
+
179
+ Write-Output ("[ledger] tick " + $tick + " -> " + $ledgerFile + " (event=" + $Event + " agent=" + $agentClean + ")")
180
+ exit 0
@@ -0,0 +1,337 @@
1
+ #!/bin/sh
2
+ # planning-with-files: append one structured entry to the run-ledger (v3).
3
+ #
4
+ # The run-ledger is the machine layer of progress tracking: an append-only
5
+ # JSON-lines file per agent under the active plan dir. Workers append here;
6
+ # the orchestrator owns progress.md and task_plan.md. See architecture C3.
7
+ #
8
+ # Plan-dir resolution (via resolve-plan-dir.sh):
9
+ # 1. $PLAN_ID env var -> ./.planning/$PLAN_ID/
10
+ # 2. ./.planning/.active_plan
11
+ # 3. Newest ./.planning/<dir>/ by mtime
12
+ # 4. Legacy: project root (ledger lands beside ./task_plan.md)
13
+ #
14
+ # Usage:
15
+ # sh scripts/ledger-append.sh <event> <summary> [options]
16
+ #
17
+ # Arguments:
18
+ # <event> one of: progress phase_complete error gate_block attest note
19
+ # <summary> free text, truncated to 200 chars, kept valid UTF-8,
20
+ # newlines stripped
21
+ #
22
+ # Options:
23
+ # --agent NAME ledger owner (default "main"); sanitized to [A-Za-z0-9_-]
24
+ # --phase N phase number/name this entry concerns (default "")
25
+ # --files f1,f2 comma-separated file list recorded as a JSON array
26
+ #
27
+ # Writes ONE JSON line to <plan-dir>/ledger-<agent>.jsonl:
28
+ # {"tick":N,"ts":"ISO8601Z","agent":"...","phase":"...",
29
+ # "event":"...","summary":"...","files":["..."]}
30
+ #
31
+ # tick = 1 + max tick across ALL ledger-*.jsonl in the plan dir, so concurrent
32
+ # agents share a monotonic counter and the stall detector (gate C2) sees one
33
+ # ordered stream.
34
+
35
+ set -u
36
+
37
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
38
+ RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
39
+
40
+ VALID_EVENTS="progress phase_complete error gate_block attest note"
41
+
42
+ usage() {
43
+ printf "Usage: %s <event> <summary> [--agent NAME] [--phase N] [--files f1,f2]\n" "$0" >&2
44
+ printf " event one of: %s\n" "${VALID_EVENTS}" >&2
45
+ }
46
+
47
+ resolve_plan_dir() {
48
+ plan_dir=""
49
+ if [ -f "${RESOLVER}" ]; then
50
+ plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
51
+ fi
52
+ if [ -n "${plan_dir}" ] && [ -d "${plan_dir}" ]; then
53
+ printf "%s\n" "${plan_dir}"
54
+ return 0
55
+ fi
56
+ # Legacy single-file mode: ledger lives beside ./task_plan.md at root.
57
+ printf "%s\n" "."
58
+ return 0
59
+ }
60
+
61
+ # Sanitize agent name to [A-Za-z0-9_-]; empty result falls back to "main".
62
+ sanitize_agent() {
63
+ raw="$1"
64
+ clean="$(printf '%s' "${raw}" | tr -cd 'A-Za-z0-9_-')"
65
+ if [ -z "${clean}" ]; then
66
+ clean="main"
67
+ fi
68
+ printf '%s' "${clean}"
69
+ }
70
+
71
+ # Escape a string for embedding inside a JSON string literal: backslash, double
72
+ # quote, and every bare control character JSON forbids. The single tr range
73
+ # 0x01-0x1F maps newline, CR, tab, vertical-tab (0x0B), form-feed (0x0C) and the
74
+ # rest of 0x01-0x08/0x0E-0x1F to spaces in one pass, matching the PS1
75
+ # ConvertTo-JsonString behavior so JSONL stays cross-platform parseable.
76
+ json_escape() {
77
+ printf '%s' "$1" \
78
+ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
79
+ | tr '\001-\037' ' '
80
+ }
81
+
82
+ # Emit $1 with any trailing incomplete UTF-8 sequence removed. GNU cut -c
83
+ # counts BYTES, so the 200 truncation below can clip a multibyte character and
84
+ # leave a tail that strict UTF-8 readers reject, poisoning the whole JSONL
85
+ # line. Preferred path: iconv -c drops every malformed byte (glibc, BSD/macOS,
86
+ # Git for Windows all ship it); its output is used whenever non-empty because
87
+ # GNU libiconv exits nonzero even after -c repaired the tail. Fallback: read
88
+ # the last <=4 bytes with od, count trailing continuation bytes (128-191),
89
+ # compare against the lead byte's declared length, drop the trailing character
90
+ # only when it is incomplete. A complete multibyte character at the boundary
91
+ # survives both paths. The fallback repairs truncation damage only; input that
92
+ # was invalid UTF-8 before truncation passes through unchanged.
93
+ utf8_trim_incomplete() {
94
+ str="$1"
95
+ if [ -z "${str}" ]; then
96
+ return 0
97
+ fi
98
+ if command -v iconv >/dev/null 2>&1; then
99
+ cleaned="$(printf '%s' "${str}" | iconv -f UTF-8 -t UTF-8 -c 2>/dev/null || true)"
100
+ if [ -n "${cleaned}" ]; then
101
+ printf '%s' "${cleaned}"
102
+ return 0
103
+ fi
104
+ # Empty output for non-empty input: iconv missing the -c flag
105
+ # (busybox) or a hard failure. Fall through to the byte-level trim.
106
+ fi
107
+ # The byte-level trim needs od, dd, and wc. On a PATH without them the
108
+ # string passes through unchanged, the pre-repair behavior: an append
109
+ # must never fail or lose the whole summary because a repair tool is
110
+ # missing.
111
+ if ! command -v od >/dev/null 2>&1 || ! command -v dd >/dev/null 2>&1; then
112
+ printf '%s' "${str}"
113
+ return 0
114
+ fi
115
+ # tr -cd normalizes BSD wc padding and yields empty when wc is absent.
116
+ nbytes="$(printf '%s' "${str}" | wc -c 2>/dev/null | tr -cd '0-9')"
117
+ if [ -z "${nbytes}" ] || [ "${nbytes}" -le 0 ]; then
118
+ printf '%s' "${str}"
119
+ return 0
120
+ fi
121
+ win=4
122
+ if [ "${nbytes}" -lt 4 ]; then
123
+ win="${nbytes}"
124
+ fi
125
+ # Last <win> bytes as decimal values, oldest first; a UTF-8 character is
126
+ # at most 4 bytes, so the window always covers the trailing character.
127
+ # shellcheck disable=SC2046
128
+ set -- $(printf '%s' "${str}" | tail -c "${win}" | od -An -tu1 | tr '\n' ' ')
129
+ last=""; prev1=""; prev2=""; prev3=""
130
+ case $# in
131
+ 1) last="$1" ;;
132
+ 2) last="$2"; prev1="$1" ;;
133
+ 3) last="$3"; prev1="$2"; prev2="$1" ;;
134
+ 4) last="$4"; prev1="$3"; prev2="$2"; prev3="$1" ;;
135
+ *) printf '%s' "${str}"; return 0 ;;
136
+ esac
137
+ cont=0
138
+ lead=""
139
+ for b in "${last}" "${prev1}" "${prev2}" "${prev3}"; do
140
+ if [ -z "${b}" ]; then
141
+ break
142
+ fi
143
+ if [ "${b}" -ge 128 ] && [ "${b}" -le 191 ]; then
144
+ cont=$((cont + 1))
145
+ else
146
+ lead="${b}"
147
+ break
148
+ fi
149
+ done
150
+ have=$((cont + 1))
151
+ strip=0
152
+ if [ -z "${lead}" ]; then
153
+ # 4+ trailing continuation bytes: invalid before truncation, keep.
154
+ strip=0
155
+ elif [ "${lead}" -lt 128 ]; then
156
+ # Stray continuations after ASCII: invalid before truncation.
157
+ strip="${cont}"
158
+ elif [ "${lead}" -ge 194 ] && [ "${lead}" -le 223 ]; then
159
+ if [ "${have}" -lt 2 ]; then strip="${have}"; fi
160
+ elif [ "${lead}" -ge 224 ] && [ "${lead}" -le 239 ]; then
161
+ if [ "${have}" -lt 3 ]; then strip="${have}"; fi
162
+ elif [ "${lead}" -ge 240 ] && [ "${lead}" -le 244 ]; then
163
+ if [ "${have}" -lt 4 ]; then strip="${have}"; fi
164
+ else
165
+ # 0xC0, 0xC1, 0xF5-0xFF are never valid UTF-8 lead bytes.
166
+ strip="${have}"
167
+ fi
168
+ if [ "${strip}" -le 0 ]; then
169
+ printf '%s' "${str}"
170
+ return 0
171
+ fi
172
+ keep=$((nbytes - strip))
173
+ if [ "${keep}" -le 0 ]; then
174
+ return 0
175
+ fi
176
+ printf '%s' "${str}" | dd bs=1 count="${keep}" 2>/dev/null
177
+ return 0
178
+ }
179
+
180
+ # Largest numeric tick already present across every ledger-*.jsonl in the dir.
181
+ # Greps the "tick":N field with sed (no jq), sorts numerically, takes the max.
182
+ # Missing/garbage files contribute nothing.
183
+ max_tick_in_dir() {
184
+ dir="$1"
185
+ max=0
186
+ for f in "${dir}"/ledger-*.jsonl; do
187
+ [ -f "${f}" ] || continue
188
+ # Extract every "tick":<digits> value, one per line.
189
+ ticks="$(sed -n 's/.*"tick"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "${f}" 2>/dev/null)"
190
+ for t in ${ticks}; do
191
+ if [ "${t}" -gt "${max}" ] 2>/dev/null; then
192
+ max="${t}"
193
+ fi
194
+ done
195
+ done
196
+ printf '%s' "${max}"
197
+ }
198
+
199
+ iso_utc() {
200
+ # ISO8601 UTC, second precision. GNU/BSD date both honor -u; fall back to
201
+ # python, then a fixed epoch-zero marker that still parses as ISO8601.
202
+ out="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
203
+ if [ -n "${out}" ]; then printf '%s' "${out}"; return 0; fi
204
+ if command -v python3 >/dev/null 2>&1; then
205
+ out="$(python3 -c "import datetime;print(datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null)"
206
+ if [ -n "${out}" ]; then printf '%s' "${out}"; return 0; fi
207
+ fi
208
+ if command -v python >/dev/null 2>&1; then
209
+ out="$(python -c "import datetime;print(datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null)"
210
+ if [ -n "${out}" ]; then printf '%s' "${out}"; return 0; fi
211
+ fi
212
+ printf '1970-01-01T00:00:00Z'
213
+ }
214
+
215
+ EVENT="${1:-}"
216
+ case "${EVENT}" in
217
+ -h|--help|"")
218
+ usage
219
+ [ -z "${EVENT}" ] && exit 2 || exit 0
220
+ ;;
221
+ esac
222
+ shift
223
+
224
+ SUMMARY="${1:-}"
225
+ if [ -z "${SUMMARY}" ]; then
226
+ printf "[ledger] missing <summary> argument.\n" >&2
227
+ usage
228
+ exit 2
229
+ fi
230
+ shift
231
+
232
+ AGENT="main"
233
+ PHASE=""
234
+ FILES_CSV=""
235
+
236
+ while [ $# -gt 0 ]; do
237
+ case "$1" in
238
+ --agent)
239
+ AGENT="${2:-}"
240
+ shift 2 || { printf "[ledger] --agent needs a value.\n" >&2; exit 2; }
241
+ ;;
242
+ --phase)
243
+ PHASE="${2:-}"
244
+ shift 2 || { printf "[ledger] --phase needs a value.\n" >&2; exit 2; }
245
+ ;;
246
+ --files)
247
+ FILES_CSV="${2:-}"
248
+ shift 2 || { printf "[ledger] --files needs a value.\n" >&2; exit 2; }
249
+ ;;
250
+ *)
251
+ printf "[ledger] unknown option: %s\n" "$1" >&2
252
+ usage
253
+ exit 2
254
+ ;;
255
+ esac
256
+ done
257
+
258
+ # Validate event against the allowlist.
259
+ valid=0
260
+ for e in ${VALID_EVENTS}; do
261
+ if [ "${EVENT}" = "${e}" ]; then valid=1; break; fi
262
+ done
263
+ if [ "${valid}" -ne 1 ]; then
264
+ printf "[ledger] invalid event '%s' (allowed: %s)\n" "${EVENT}" "${VALID_EVENTS}" >&2
265
+ exit 2
266
+ fi
267
+
268
+ AGENT="$(sanitize_agent "${AGENT}")"
269
+
270
+ # Truncate summary to 200 BEFORE escaping (200 is a source-text budget).
271
+ # GNU cut -c counts bytes and can land mid-codepoint on multibyte input;
272
+ # BSD cut -c counts characters and clips cleanly. The trim removes any
273
+ # incomplete trailing UTF-8 sequence so the JSONL line stays valid UTF-8.
274
+ SUMMARY="$(printf '%s' "${SUMMARY}" | cut -c1-200)"
275
+ SUMMARY="$(utf8_trim_incomplete "${SUMMARY}")"
276
+
277
+ PLAN_DIR="$(resolve_plan_dir)"
278
+ LEDGER_FILE="${PLAN_DIR}/ledger-${AGENT}.jsonl"
279
+ LOCK_FILE="${PLAN_DIR}/.ledger_lock"
280
+
281
+ TS="$(iso_utc)"
282
+
283
+ # Build the files JSON array from the comma-separated list.
284
+ FILES_JSON="[]"
285
+ if [ -n "${FILES_CSV}" ]; then
286
+ FILES_JSON="["
287
+ first=1
288
+ # Word-split on commas only.
289
+ OLD_IFS="$IFS"
290
+ IFS=','
291
+ for item in ${FILES_CSV}; do
292
+ IFS="$OLD_IFS"
293
+ [ -z "${item}" ] && { IFS=','; continue; }
294
+ esc="$(json_escape "${item}")"
295
+ if [ "${first}" -eq 1 ]; then
296
+ FILES_JSON="${FILES_JSON}\"${esc}\""
297
+ first=0
298
+ else
299
+ FILES_JSON="${FILES_JSON},\"${esc}\""
300
+ fi
301
+ IFS=','
302
+ done
303
+ IFS="$OLD_IFS"
304
+ FILES_JSON="${FILES_JSON}]"
305
+ fi
306
+
307
+ SUMMARY_ESC="$(json_escape "${SUMMARY}")"
308
+ PHASE_ESC="$(json_escape "${PHASE}")"
309
+
310
+ # Append under an advisory flock when available. The single printf write keeps
311
+ # the line atomic-enough on platforms without flock (line-buffered, <4KB).
312
+ append_line() {
313
+ tick="$(max_tick_in_dir "${PLAN_DIR}")"
314
+ tick=$((tick + 1))
315
+ printf '{"tick":%s,"ts":"%s","agent":"%s","phase":"%s","event":"%s","summary":"%s","files":%s}\n' \
316
+ "${tick}" "${TS}" "${AGENT}" "${PHASE_ESC}" "${EVENT}" "${SUMMARY_ESC}" "${FILES_JSON}" \
317
+ >> "${LEDGER_FILE}"
318
+ printf '%s' "${tick}"
319
+ }
320
+
321
+ if command -v flock >/dev/null 2>&1; then
322
+ # Compute tick AND write while holding the lock so concurrent appenders do
323
+ # not pick the same tick number. The subshell scopes fd 9 to the lock.
324
+ written_tick="$(
325
+ (
326
+ flock -w 5 9 || true
327
+ append_line
328
+ ) 9>"${LOCK_FILE}" 2>/dev/null
329
+ )"
330
+ rm -f "${LOCK_FILE}" 2>/dev/null || true
331
+ else
332
+ written_tick="$(append_line)"
333
+ fi
334
+
335
+ printf "[ledger] tick %s -> %s (event=%s agent=%s)\n" \
336
+ "${written_tick:-?}" "${LEDGER_FILE}" "${EVENT}" "${AGENT}"
337
+ exit 0
@@ -0,0 +1,128 @@
1
+ #requires -Version 5.0
2
+ <#
3
+ .SYNOPSIS
4
+ Emit a fixed-shape, cache-stable run-ledger summary (PowerShell mirror, v3).
5
+
6
+ .DESCRIPTION
7
+ Replaces raw progress.md tail injection in autonomous mode. Output is
8
+ synthesized from the machine ledger and task_plan.md status counts only:
9
+ NO free text from disk reaches model context, and NO timestamps, so the
10
+ injected block is KV-cache stable by construction (architecture C3).
11
+
12
+ Plan-dir resolution matches resolve-plan-dir.ps1:
13
+ 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
14
+ 2. .\.planning\.active_plan
15
+ 3. Newest .\.planning\<dir>\ by LastWriteTime
16
+ 4. Legacy: project root
17
+
18
+ Output block (stable shape):
19
+ === RUN LEDGER ===
20
+ entries: <N>
21
+ phases: <complete>/<total> complete
22
+ in_progress: <phase heading or none>
23
+ agent <name>: <last event type>
24
+ ==================
25
+ #>
26
+ [CmdletBinding()]
27
+ param()
28
+
29
+ $ErrorActionPreference = "Stop"
30
+
31
+ function Resolve-PlanDir {
32
+ $planRoot = Join-Path (Get-Location) ".planning"
33
+
34
+ if ($env:PLAN_ID) {
35
+ $candidate = Join-Path $planRoot $env:PLAN_ID
36
+ if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
37
+ }
38
+
39
+ $activePointer = Join-Path $planRoot ".active_plan"
40
+ if (Test-Path -LiteralPath $activePointer) {
41
+ $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
42
+ if ($planId) {
43
+ $candidate = Join-Path $planRoot $planId
44
+ if (Test-Path -LiteralPath $candidate -PathType Container) { return $candidate }
45
+ }
46
+ }
47
+
48
+ if (Test-Path -LiteralPath $planRoot -PathType Container) {
49
+ $newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
50
+ Where-Object { -not $_.Name.StartsWith(".") } |
51
+ Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
52
+ Sort-Object LastWriteTime -Descending |
53
+ Select-Object -First 1
54
+ if ($newest) { return $newest.FullName }
55
+ }
56
+
57
+ return (Get-Location).Path
58
+ }
59
+
60
+ $planDir = Resolve-PlanDir
61
+ $planFile = Join-Path $planDir "task_plan.md"
62
+
63
+ # --- Phase counts: same patterns as check-complete.ps1 ---
64
+ $TOTAL = 0
65
+ $COMPLETE = 0
66
+ $IN_PROGRESS = 0
67
+ $inProgressHeading = "none"
68
+
69
+ if (Test-Path -LiteralPath $planFile) {
70
+ $content = Get-Content -LiteralPath $planFile -Raw
71
+ $TOTAL = ([regex]::Matches($content, "### Phase")).Count
72
+ $COMPLETE = ([regex]::Matches($content, "\*\*Status:\*\* complete")).Count
73
+ $IN_PROGRESS = ([regex]::Matches($content, "\*\*Status:\*\* in_progress")).Count
74
+
75
+ if ($COMPLETE -eq 0 -and $IN_PROGRESS -eq 0) {
76
+ $c2 = ([regex]::Matches($content, "\[complete\]")).Count
77
+ $i2 = ([regex]::Matches($content, "\[in_progress\]")).Count
78
+ if ($c2 -gt 0 -or $i2 -gt 0) {
79
+ $COMPLETE = $c2
80
+ $IN_PROGRESS = $i2
81
+ }
82
+ }
83
+
84
+ # Heading of the first phase block whose status is in_progress.
85
+ $heading = ""
86
+ foreach ($line in (Get-Content -LiteralPath $planFile)) {
87
+ if ($line -match "^### Phase") {
88
+ $heading = $line
89
+ } elseif ($line -match "\*\*Status:\*\* in_progress" -or $line -match "\[in_progress\]") {
90
+ if ($heading) {
91
+ $inProgressHeading = $heading
92
+ break
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ # --- Ledger stats ---
99
+ $totalEntries = 0
100
+ $ledgerFiles = Get-ChildItem -LiteralPath $planDir -Filter "ledger-*.jsonl" -File -ErrorAction SilentlyContinue
101
+ foreach ($f in $ledgerFiles) {
102
+ $lines = Get-Content -LiteralPath $f.FullName -ErrorAction SilentlyContinue
103
+ foreach ($line in $lines) {
104
+ if ($line -match '"tick"') { $totalEntries++ }
105
+ }
106
+ }
107
+
108
+ Write-Output "=== RUN LEDGER ==="
109
+ Write-Output ("entries: " + $totalEntries)
110
+ Write-Output ("phases: " + $COMPLETE + "/" + $TOTAL + " complete")
111
+ Write-Output ("in_progress: " + $inProgressHeading)
112
+
113
+ foreach ($f in $ledgerFiles) {
114
+ $agent = $f.Name -replace '^ledger-', '' -replace '\.jsonl$', ''
115
+ # @(...) forces array semantics: a single-line file returns a string from
116
+ # Get-Content and $lines[-1] would otherwise index the last character.
117
+ $lines = @(Get-Content -LiteralPath $f.FullName -ErrorAction SilentlyContinue)
118
+ $lastEvent = "none"
119
+ if ($lines.Count -gt 0) {
120
+ $lastLine = $lines[$lines.Count - 1]
121
+ $m = [regex]::Match($lastLine, '"event"\s*:\s*"([A-Za-z_]+)"')
122
+ if ($m.Success) { $lastEvent = $m.Groups[1].Value }
123
+ }
124
+ Write-Output ("agent " + $agent + ": " + $lastEvent)
125
+ }
126
+
127
+ Write-Output "=================="
128
+ exit 0