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.
- package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -228
- package/extensions/planning-with-files/package.json +17 -17
- package/extensions/planning-with-files/runtime.ts +788 -788
- package/package.json +1 -1
- package/scripts/attest-plan.ps1 +137 -137
- package/scripts/attest-plan.sh +206 -206
- package/scripts/check-complete.ps1 +253 -253
- package/scripts/check-complete.sh +253 -253
- package/scripts/gate-stop.sh +32 -32
- package/scripts/ledger-append.ps1 +180 -180
- package/scripts/ledger-append.sh +337 -337
- package/scripts/ledger-summary.ps1 +128 -128
- package/scripts/phase-status.ps1 +175 -175
- package/scripts/phase-status.sh +158 -158
- package/scripts/session-catchup.py +876 -876
- package/scripts/set-active-plan.ps1 +51 -51
- package/scripts/set-active-plan.sh +50 -50
package/scripts/ledger-append.sh
CHANGED
|
@@ -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
|