Files

347 lines
12 KiB
Bash

#!/bin/sh
# planning-with-files: append one structured entry to the run-ledger (v3).
#
# The run-ledger is the machine layer of progress tracking: an append-only
# JSON-lines file per agent under the active plan dir. Workers append here;
# the orchestrator owns progress.md and task_plan.md. See architecture C3.
#
# Plan-dir resolution (via resolve-plan-dir.sh):
# 1. $PLAN_ID env var -> ./.planning/$PLAN_ID/
# 2. ./.planning/.active_plan
# 3. Newest ./.planning/<dir>/ by mtime
# 4. Legacy: project root (ledger lands beside ./task_plan.md)
#
# Usage:
# sh scripts/ledger-append.sh <event> <summary> [options]
#
# Arguments:
# <event> one of: progress phase_complete error gate_block attest note
# <summary> free text, truncated to 200 chars, kept valid UTF-8,
# newlines stripped
#
# Options:
# --agent NAME ledger owner (default "main"); sanitized to [A-Za-z0-9_-]
# --phase N phase number/name this entry concerns (default "")
# --files f1,f2 comma-separated file list recorded as a JSON array
#
# Writes ONE JSON line to <plan-dir>/ledger-<agent>.jsonl:
# {"tick":N,"ts":"ISO8601Z","agent":"...","phase":"...",
# "event":"...","summary":"...","files":["..."]}
#
# tick = 1 + max tick across ALL ledger-*.jsonl in the plan dir, so concurrent
# agents share a monotonic counter and the stall detector (gate C2) sees one
# ordered stream.
set -u
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RESOLVER="${SCRIPT_DIR}/resolve-plan-dir.sh"
VALID_EVENTS="progress phase_complete error gate_block attest note"
usage() {
printf "Usage: %s <event> <summary> [--agent NAME] [--phase N] [--files f1,f2]\n" "$0" >&2
printf " event one of: %s\n" "${VALID_EVENTS}" >&2
}
resolve_plan_dir() {
plan_dir=""
if [ -f "${RESOLVER}" ]; then
plan_dir="$(sh "${RESOLVER}" 2>/dev/null)"
fi
if [ -n "${plan_dir}" ] && [ -d "${plan_dir}" ]; then
printf "%s\n" "${plan_dir}"
return 0
fi
# Explicit selectors are bindings, not hints (issue #237). This script
# WRITES ledger rows into the plan dir it picks, so a legacy cwd fallback
# after a rejected selector files another plan's run history.
if [ -n "${PLAN_ID:-}" ] || [ -n "${PWF_PLAN_ROOT:-}" ]; then
return 1
fi
# Legacy single-file mode: ledger lives beside ./task_plan.md at root.
printf "%s\n" "."
return 0
}
# Sanitize agent name to [A-Za-z0-9_-]; empty result falls back to "main".
sanitize_agent() {
raw="$1"
clean="$(printf '%s' "${raw}" | tr -cd 'A-Za-z0-9_-')"
if [ -z "${clean}" ]; then
clean="main"
fi
printf '%s' "${clean}"
}
# Escape a string for embedding inside a JSON string literal: backslash, double
# quote, and every bare control character JSON forbids. The single tr range
# 0x01-0x1F maps newline, CR, tab, vertical-tab (0x0B), form-feed (0x0C) and the
# rest of 0x01-0x08/0x0E-0x1F to spaces in one pass, matching the PS1
# ConvertTo-JsonString behavior so JSONL stays cross-platform parseable.
json_escape() {
printf '%s' "$1" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
| tr '\001-\037' ' '
}
# Emit $1 with any trailing incomplete UTF-8 sequence removed. GNU cut -c
# counts BYTES, so the 200 truncation below can clip a multibyte character and
# leave a tail that strict UTF-8 readers reject, poisoning the whole JSONL
# line. Preferred path: iconv -c drops every malformed byte (glibc, BSD/macOS,
# Git for Windows all ship it); its output is used whenever non-empty because
# GNU libiconv exits nonzero even after -c repaired the tail. Fallback: read
# the last <=4 bytes with od, count trailing continuation bytes (128-191),
# compare against the lead byte's declared length, drop the trailing character
# only when it is incomplete. A complete multibyte character at the boundary
# survives both paths. The fallback repairs truncation damage only; input that
# was invalid UTF-8 before truncation passes through unchanged.
utf8_trim_incomplete() {
str="$1"
if [ -z "${str}" ]; then
return 0
fi
if command -v iconv >/dev/null 2>&1; then
cleaned="$(printf '%s' "${str}" | iconv -f UTF-8 -t UTF-8 -c 2>/dev/null || true)"
if [ -n "${cleaned}" ]; then
printf '%s' "${cleaned}"
return 0
fi
# Empty output for non-empty input: iconv missing the -c flag
# (busybox) or a hard failure. Fall through to the byte-level trim.
fi
# The byte-level trim needs od, dd, and wc. On a PATH without them the
# string passes through unchanged, the pre-repair behavior: an append
# must never fail or lose the whole summary because a repair tool is
# missing.
if ! command -v od >/dev/null 2>&1 || ! command -v dd >/dev/null 2>&1; then
printf '%s' "${str}"
return 0
fi
# tr -cd normalizes BSD wc padding and yields empty when wc is absent.
nbytes="$(printf '%s' "${str}" | wc -c 2>/dev/null | tr -cd '0-9')"
if [ -z "${nbytes}" ] || [ "${nbytes}" -le 0 ]; then
printf '%s' "${str}"
return 0
fi
win=4
if [ "${nbytes}" -lt 4 ]; then
win="${nbytes}"
fi
# Last <win> bytes as decimal values, oldest first; a UTF-8 character is
# at most 4 bytes, so the window always covers the trailing character.
# shellcheck disable=SC2046
set -- $(printf '%s' "${str}" | tail -c "${win}" | od -An -tu1 | tr '\n' ' ')
last=""; prev1=""; prev2=""; prev3=""
case $# in
1) last="$1" ;;
2) last="$2"; prev1="$1" ;;
3) last="$3"; prev1="$2"; prev2="$1" ;;
4) last="$4"; prev1="$3"; prev2="$2"; prev3="$1" ;;
*) printf '%s' "${str}"; return 0 ;;
esac
cont=0
lead=""
for b in "${last}" "${prev1}" "${prev2}" "${prev3}"; do
if [ -z "${b}" ]; then
break
fi
if [ "${b}" -ge 128 ] && [ "${b}" -le 191 ]; then
cont=$((cont + 1))
else
lead="${b}"
break
fi
done
have=$((cont + 1))
strip=0
if [ -z "${lead}" ]; then
# 4+ trailing continuation bytes: invalid before truncation, keep.
strip=0
elif [ "${lead}" -lt 128 ]; then
# Stray continuations after ASCII: invalid before truncation.
strip="${cont}"
elif [ "${lead}" -ge 194 ] && [ "${lead}" -le 223 ]; then
if [ "${have}" -lt 2 ]; then strip="${have}"; fi
elif [ "${lead}" -ge 224 ] && [ "${lead}" -le 239 ]; then
if [ "${have}" -lt 3 ]; then strip="${have}"; fi
elif [ "${lead}" -ge 240 ] && [ "${lead}" -le 244 ]; then
if [ "${have}" -lt 4 ]; then strip="${have}"; fi
else
# 0xC0, 0xC1, 0xF5-0xFF are never valid UTF-8 lead bytes.
strip="${have}"
fi
if [ "${strip}" -le 0 ]; then
printf '%s' "${str}"
return 0
fi
keep=$((nbytes - strip))
if [ "${keep}" -le 0 ]; then
return 0
fi
printf '%s' "${str}" | dd bs=1 count="${keep}" 2>/dev/null
return 0
}
# Largest numeric tick already present across every ledger-*.jsonl in the dir.
# Greps the "tick":N field with sed (no jq), sorts numerically, takes the max.
# Missing/garbage files contribute nothing.
max_tick_in_dir() {
dir="$1"
max=0
for f in "${dir}"/ledger-*.jsonl; do
[ -f "${f}" ] || continue
# Extract every "tick":<digits> value, one per line.
ticks="$(sed -n 's/.*"tick"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' "${f}" 2>/dev/null)"
for t in ${ticks}; do
if [ "${t}" -gt "${max}" ] 2>/dev/null; then
max="${t}"
fi
done
done
printf '%s' "${max}"
}
iso_utc() {
# ISO8601 UTC, second precision. GNU/BSD date both honor -u; fall back to
# python, then a fixed epoch-zero marker that still parses as ISO8601.
out="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)"
if [ -n "${out}" ]; then printf '%s' "${out}"; return 0; fi
if command -v python3 >/dev/null 2>&1; then
out="$(python3 -c "import datetime;print(datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null)"
if [ -n "${out}" ]; then printf '%s' "${out}"; return 0; fi
fi
if command -v python >/dev/null 2>&1; then
out="$(python -c "import datetime;print(datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'))" 2>/dev/null)"
if [ -n "${out}" ]; then printf '%s' "${out}"; return 0; fi
fi
printf '1970-01-01T00:00:00Z'
}
EVENT="${1:-}"
case "${EVENT}" in
-h|--help|"")
usage
[ -z "${EVENT}" ] && exit 2 || exit 0
;;
esac
shift
SUMMARY="${1:-}"
if [ -z "${SUMMARY}" ]; then
printf "[ledger] missing <summary> argument.\n" >&2
usage
exit 2
fi
shift
AGENT="main"
PHASE=""
FILES_CSV=""
while [ $# -gt 0 ]; do
case "$1" in
--agent)
AGENT="${2:-}"
shift 2 || { printf "[ledger] --agent needs a value.\n" >&2; exit 2; }
;;
--phase)
PHASE="${2:-}"
shift 2 || { printf "[ledger] --phase needs a value.\n" >&2; exit 2; }
;;
--files)
FILES_CSV="${2:-}"
shift 2 || { printf "[ledger] --files needs a value.\n" >&2; exit 2; }
;;
*)
printf "[ledger] unknown option: %s\n" "$1" >&2
usage
exit 2
;;
esac
done
# Validate event against the allowlist.
valid=0
for e in ${VALID_EVENTS}; do
if [ "${EVENT}" = "${e}" ]; then valid=1; break; fi
done
if [ "${valid}" -ne 1 ]; then
printf "[ledger] invalid event '%s' (allowed: %s)\n" "${EVENT}" "${VALID_EVENTS}" >&2
exit 2
fi
AGENT="$(sanitize_agent "${AGENT}")"
# Truncate summary to 200 BEFORE escaping (200 is a source-text budget).
# GNU cut -c counts bytes and can land mid-codepoint on multibyte input;
# BSD cut -c counts characters and clips cleanly. The trim removes any
# incomplete trailing UTF-8 sequence so the JSONL line stays valid UTF-8.
SUMMARY="$(printf '%s' "${SUMMARY}" | cut -c1-200)"
SUMMARY="$(utf8_trim_incomplete "${SUMMARY}")"
PLAN_DIR="$(resolve_plan_dir)" || {
printf "[ledger-append] An explicit PLAN_ID or PWF_PLAN_ROOT did not resolve to a plan directory; nothing was written and no other plan was substituted.\n" >&2
exit 1
}
LEDGER_FILE="${PLAN_DIR}/ledger-${AGENT}.jsonl"
LOCK_FILE="${PLAN_DIR}/.ledger_lock"
TS="$(iso_utc)"
# Build the files JSON array from the comma-separated list.
FILES_JSON="[]"
if [ -n "${FILES_CSV}" ]; then
FILES_JSON="["
first=1
# Word-split on commas only.
OLD_IFS="$IFS"
IFS=','
for item in ${FILES_CSV}; do
IFS="$OLD_IFS"
[ -z "${item}" ] && { IFS=','; continue; }
esc="$(json_escape "${item}")"
if [ "${first}" -eq 1 ]; then
FILES_JSON="${FILES_JSON}\"${esc}\""
first=0
else
FILES_JSON="${FILES_JSON},\"${esc}\""
fi
IFS=','
done
IFS="$OLD_IFS"
FILES_JSON="${FILES_JSON}]"
fi
SUMMARY_ESC="$(json_escape "${SUMMARY}")"
PHASE_ESC="$(json_escape "${PHASE}")"
# Append under an advisory flock when available. The single printf write keeps
# the line atomic-enough on platforms without flock (line-buffered, <4KB).
append_line() {
tick="$(max_tick_in_dir "${PLAN_DIR}")"
tick=$((tick + 1))
printf '{"tick":%s,"ts":"%s","agent":"%s","phase":"%s","event":"%s","summary":"%s","files":%s}\n' \
"${tick}" "${TS}" "${AGENT}" "${PHASE_ESC}" "${EVENT}" "${SUMMARY_ESC}" "${FILES_JSON}" \
>> "${LEDGER_FILE}"
printf '%s' "${tick}"
}
if command -v flock >/dev/null 2>&1; then
# Compute tick AND write while holding the lock so concurrent appenders do
# not pick the same tick number. The subshell scopes fd 9 to the lock.
written_tick="$(
(
flock -w 5 9 || true
append_line
) 9>"${LOCK_FILE}" 2>/dev/null
)"
rm -f "${LOCK_FILE}" 2>/dev/null || true
else
written_tick="$(append_line)"
fi
printf "[ledger] tick %s -> %s (event=%s agent=%s)\n" \
"${written_tick:-?}" "${LEDGER_FILE}" "${EVENT}" "${AGENT}"
exit 0