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.
@@ -1,337 +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
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