Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "throughline",
"version": "3.41.0",
"version": "3.42.0",
"description": "Throughline plugin: a PRD/TDD/ADR design-doc pipeline with phase-gate PRs, layered as a thin governance overlay on top of the official superpowers/pr-review-toolkit plugins (it owns governance; they own discovery + engineering). /prd-author requires an observable acceptance criterion per new requirement. /tdd-author decides how many TDDs a PRD change needs (git-diff + coverage), recommends ADR actions, requires an alternatives analysis for every new dependency, requires a verification plan (observable surface → observation point(s) → expected observations) per TDD, reads BLOCKERS.md so implementation-time design blockers feed back into design, self-reviews, then runs an independent design-critique gate (different model, fresh context) before the design PR. /implement builds every TDD merged to the integration branch unattended in detached processes on the latest top-tier model but does not trust self-reported success: the flip to implemented is gated on failing-test-first discipline, a mechanical ci-checks gate (tests+typecheck+lint — CI's job), a runtime-verify gate that drives the built artifact at its observable surface (PASS/FAIL/BLOCKED/SKIP kept distinct; mechanism delegated, no harness vendored — ADR 0004), and an independent review on a DIFFERENT model (the prior generation's top tier) for reviewer diversity — one stacked PR per TDD, downstream halt-on-failure, never merges. Includes a toolchain bootstrap skill and a format+lint hook for JS/TS, Python, Rust, and Go.",
"author": {
"name": "Chris Henesy"
Expand Down
3 changes: 2 additions & 1 deletion docs/tdd/0061-file-backed-gate-verdicts.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# TDD 0061: File-backed four-gate verdicts and durable run record

Status: draft
Status: implemented
build-engine: bootstrap
PRD refs: FR-15, FR-16, FR-25, FR-27, FR-39, FR-40, FR-41, FR-42, FR-43, FR-44, FR-45, FR-63, FR-64, FR-67, FR-70, FR-82
PRD-rev: 373cd89
ADR constraints: 0004, 0005, 0006, 0011, 0013
Expand Down
193 changes: 193 additions & 0 deletions scripts/lib/run-record.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# run-record.sh — durable /build-tdds run record (TDD 0061 / FR-27, FR-39,
# FR-40, FR-43, FR-18). Queue, status, halt, lock, next-gate. Does not
# read a harness transcript. No top-level side effects.
#
# Every tl_run_* takes <repo-root> first (absolute). Never uses cwd as
# the logs root.

_TL_RUN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/verdicts.sh
{ [ -r "${_TL_RUN_DIR}/verdicts.sh" ] && . "${_TL_RUN_DIR}/verdicts.sh"; } || {
echo "FATAL: cannot source ${_TL_RUN_DIR}/verdicts.sh" >&2
return 1 2>/dev/null || exit 1
}

_tl_run_abs() {
case "${1:-}" in
/*) return 0 ;;
*) echo "run-record: <repo-root> must be an absolute path" >&2; return 2 ;;
esac
}

_tl_run_logs() { # <repo-root>
_tl_run_abs "$1" || return $?
printf '%s/docs/tdd/.implement-logs\n' "$1"
}

_tl_run_now() { date -u +%Y-%m-%dT%H:%M:%SZ; }

# Atomic write: printf to mktemp then mv. A kill -9 before mv leaves the
# previous file intact (FR-44).
_tl_run_atomic() { # <dest> <contents>
local dest="$1" body="$2" tmp dir
dir="$(dirname "$dest")"
mkdir -p "$dir" || return 1
tmp="$(mktemp "$dir/.$(basename "$dest").XXXXXX")" || return 1
if ! printf '%s\n' "$body" >"$tmp"; then rm -f "$tmp"; return 1; fi
if ! mv "$tmp" "$dest"; then rm -f "$tmp"; return 1; fi
return 0
}

_tl_run_json() { # <run-id> <status> <started> <updated> <total> <done>
printf '{"run_id":"%s","status":"%s","started_at":"%s","updated_at":"%s","tdd_total":"%s","tdd_done":"%s"}' \
"$(tl_json_escape "$1")" "$(tl_json_escape "$2")" \
"$(tl_json_escape "$3")" "$(tl_json_escape "$4")" \
"$(tl_json_escape "$5")" "$(tl_json_escape "$6")"
}

tl_run_init() { # <repo-root> <run-id>
local root="${1:-}" run="${2:-}" logs dir now
logs="$(_tl_run_logs "$root")" || return $?
_tl_valid_run "$run" || { echo "run-record: invalid run-id" >&2; return 2; }
dir="$logs/$run"
mkdir -p "$dir" || return 1
now="$(_tl_run_now)"
_tl_run_atomic "$dir/run.json" "$(_tl_run_json "$run" running "$now" "$now" 0 0)" || return 1
ln -sfn "$dir" "$logs/latest" || return 1
}

_tl_run_read_run() { # <run.json path> <key>
[ -f "$1" ] || return 1
tl_json_field "$2" <"$1"
}

tl_run_set_tdd() { # <repo-root> <run-id> <slug> <status> [halt_cause] [current_gate]
local root="${1:-}" run="${2:-}" slug="${3:-}" status="${4:-}"
local cause="${5:-}" gate="${6:-}" logs rfile sfile now
local qidx started pr logp total done prev
logs="$(_tl_run_logs "$root")" || return $?
_tl_valid_run "$run" || { echo "run-record: invalid run-id" >&2; return 2; }
_tl_valid_slug "$slug" || { echo "run-record: invalid slug '$slug'" >&2; return 2; }
case "$status" in
pending|building|verifying|reviewing|done|failed|blocked|skipped|paused) ;;
*) echo "run-record: bad status '$status'" >&2; return 2 ;;
esac
if [ -n "$cause" ]; then
case "$cause" in
ratelimit|usage-limit|transient|resume-blocked-build-state-missing|resume-blocked-branch-missing|resume-blocked-branch-divergence|structural-finding|design-escalation|external-blocker|gate-fail) ;;
*) echo "run-record: bad halt_cause '$cause'" >&2; return 2 ;;
esac
fi
rfile="$logs/$run/run.json"
[ -f "$rfile" ] || { echo "run-record: run $run not initialized" >&2; return 1; }
sfile="$logs/$run/${slug}.json"
now="$(_tl_run_now)"
logp="$(tl_verdict_dir "$root" "$run" "$slug")" || return $?
total="$(_tl_run_read_run "$rfile" tdd_total)"; total="${total:-0}"
done="$(_tl_run_read_run "$rfile" tdd_done)"; done="${done:-0}"
if [ -f "$sfile" ]; then
qidx="$(tl_json_field queue_index <"$sfile")"
started="$(tl_json_field started_at <"$sfile")"
pr="$(tl_json_field pr_url <"$sfile")"
prev="$(tl_json_field status <"$sfile")"
else
qidx="$total"
started="$now"
pr=""
prev=""
total=$((total + 1))
fi
if [ "$status" = "done" ] && [ "$prev" != "done" ]; then
done=$((done + 1))
fi
_tl_run_atomic "$sfile" "$(printf \
'{"slug":"%s","status":"%s","halt_cause":"%s","queue_index":"%s","current_gate":"%s","started_at":"%s","updated_at":"%s","pr_url":"%s","log_path":"%s"}' \
"$(tl_json_escape "$slug")" "$(tl_json_escape "$status")" \
"$(tl_json_escape "$cause")" "$(tl_json_escape "$qidx")" \
"$(tl_json_escape "$gate")" "$(tl_json_escape "$started")" \
"$(tl_json_escape "$now")" "$(tl_json_escape "$pr")" \
"$(tl_json_escape "$logp")")" || return 1
_tl_run_atomic "$rfile" "$(_tl_run_json "$run" \
"$(_tl_run_read_run "$rfile" status || printf running)" \
"$(_tl_run_read_run "$rfile" started_at || printf '%s' "$now")" \
"$now" "$total" "$done")" || return 1
}

tl_run_set_pr() { # <repo-root> <run-id> <slug> <url>
local root="${1:-}" run="${2:-}" slug="${3:-}" url="${4:-}"
local logs sfile now qidx started status cause gate logp
logs="$(_tl_run_logs "$root")" || return $?
sfile="$logs/$run/${slug}.json"
[ -f "$sfile" ] || { echo "run-record: no fragment for $slug" >&2; return 1; }
now="$(_tl_run_now)"
status="$(tl_json_field status <"$sfile")"
cause="$(tl_json_field halt_cause <"$sfile")"
qidx="$(tl_json_field queue_index <"$sfile")"
gate="$(tl_json_field current_gate <"$sfile")"
started="$(tl_json_field started_at <"$sfile")"
logp="$(tl_json_field log_path <"$sfile")"
_tl_run_atomic "$sfile" "$(printf \
'{"slug":"%s","status":"%s","halt_cause":"%s","queue_index":"%s","current_gate":"%s","started_at":"%s","updated_at":"%s","pr_url":"%s","log_path":"%s"}' \
"$(tl_json_escape "$slug")" "$(tl_json_escape "$status")" \
"$(tl_json_escape "$cause")" "$(tl_json_escape "$qidx")" \
"$(tl_json_escape "$gate")" "$(tl_json_escape "$started")" \
"$(tl_json_escape "$now")" "$(tl_json_escape "$url")" \
"$(tl_json_escape "$logp")")"
}

_tl_lock_path() { printf '%s/.run.lock\n' "$(_tl_run_logs "$1")" || return $?; }

tl_run_lock() { # <repo-root>
local root="${1:-}" lock pid logs
logs="$(_tl_run_logs "$root")" || return $?
mkdir -p "$logs" || return 1
lock="$logs/.run.lock"
if [ -f "$lock" ]; then
pid="$(tr -d ' \n' <"$lock")"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "run-record: lock held by live PID $pid" >&2
return 1
fi
fi
printf '%s\n' "$$" >"$lock"
}

tl_run_unlock() { # <repo-root>
local lock
lock="$(_tl_lock_path "$1")" || return $?
rm -f "$lock"
}

tl_run_lock_reclaim() { # <repo-root>
local root="${1:-}" lock pid logs
logs="$(_tl_run_logs "$root")" || return $?
mkdir -p "$logs" || return 1
lock="$logs/.run.lock"
if [ -f "$lock" ]; then
pid="$(tr -d ' \n' <"$lock")"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "run-record: cannot reclaim; PID $pid is live" >&2
return 1
fi
fi
printf '%s\n' "$$" >"$lock"
}

tl_run_next_gate() { # <repo-root> <run-id> <slug>
local root="${1:-}" run="${2:-}" slug="${3:-}" g json st
if tl_verdict_require_flip "$root" "$run" "$slug" 2>/dev/null; then
printf 'flip\n'
return 0
fi
for g in test-first ci-checks runtime-verify review; do
json="$(tl_verdict_read "$root" "$run" "$slug" "$g" 2>/dev/null)" || { printf '%s\n' "$g"; return 0; }
st="$(printf '%s' "$json" | tl_json_field status)"
case "$st" in
PASS|SKIP) ;;
*) printf '%s\n' "$g"; return 0 ;;
esac
done
# Files look complete but require_flip failed (e.g. SKIP without evidence).
printf 'runtime-verify\n'
}
157 changes: 157 additions & 0 deletions scripts/lib/verdicts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# verdicts.sh — file-backed four-gate verdicts (TDD 0061 / FR-15, FR-25, FR-82,
# ADR 0011). Flip authority is four JSON files under
# <repo-root>/docs/tdd/.implement-logs/<run-id>/<slug>/<gate>.json
# Never reads a harness transcript. No top-level side effects.
#
# Every tl_verdict_* / tl_test_first_observe takes an explicit path; none
# consult the process cwd as <repo-root>.

_TL_VERDICTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_jlib="${_TL_VERDICTS_DIR}/json.sh"
# shellcheck source=scripts/lib/json.sh
{ [ -r "$_jlib" ] && . "$_jlib"; } || {
echo "FATAL: cannot source $_jlib (partial install or perms)" >&2
return 1 2>/dev/null || exit 1
}
unset _jlib

_tl_verdict_gates='test-first|ci-checks|runtime-verify|review'
_tl_verdict_statuses='PASS|FAIL|BLOCKED|SKIP'

# _tl_abs_root <path> — rc 0 iff <path> is a non-empty absolute directory
# path (need not exist yet). Rejects empty and relative so callers cannot
# silently write under cwd.
_tl_abs_root() {
case "${1:-}" in
/*) return 0 ;;
*) echo "verdicts: <repo-root> must be an absolute path" >&2; return 2 ;;
esac
}

# _tl_valid_slug <slug> — ^[a-z0-9][a-z0-9.-]*$
_tl_valid_slug() {
case "${1:-}" in
''|*[!a-z0-9.-]*|.*|-*) return 1 ;;
esac
case "${1}" in
[a-z0-9]*) return 0 ;;
*) return 1 ;;
esac
}

# _tl_valid_run <run-id> — no slashes; non-empty path segment.
_tl_valid_run() {
case "${1:-}" in
''|*/*|*..*) return 1 ;;
*) return 0 ;;
esac
}

tl_verdict_dir() { # <repo-root> <run-id> <slug>
local root="${1:-}" run="${2:-}" slug="${3:-}"
_tl_abs_root "$root" || return $?
_tl_valid_run "$run" || { echo "verdicts: invalid run-id" >&2; return 2; }
_tl_valid_slug "$slug" || { echo "verdicts: invalid slug '$slug'" >&2; return 2; }
printf '%s/docs/tdd/.implement-logs/%s/%s\n' "$root" "$run" "$slug"
}

tl_verdict_write() { # <repo-root> <run-id> <slug> <gate> <status> <evidence>
local root="${1:-}" run="${2:-}" slug="${3:-}" gate="${4:-}" status="${5:-}" evidence="${6:-}"
local dir f tmp
dir="$(tl_verdict_dir "$root" "$run" "$slug")" || return $?
case "$gate" in
test-first|ci-checks|runtime-verify|review) ;;
*) echo "verdicts: bad gate token '$gate' (want ${_tl_verdict_gates})" >&2; return 2 ;;
esac
case "$status" in
PASS|FAIL|BLOCKED|SKIP) ;;
*) echo "verdicts: bad status token '$status' (want ${_tl_verdict_statuses})" >&2; return 2 ;;
esac
mkdir -p "$dir" || { echo "verdicts: cannot mkdir $dir" >&2; return 1; }
f="$dir/${gate}.json"
tmp="$(mktemp "$dir/.${gate}.XXXXXX")" || { echo "verdicts: mktemp failed" >&2; return 1; }
if ! printf '{"gate":"%s","status":"%s","evidence":"%s"}\n' \
"$(tl_json_escape "$gate")" \
"$(tl_json_escape "$status")" \
"$(tl_json_escape "$evidence")" >"$tmp"; then
rm -f "$tmp"
echo "verdicts: write failed for $f" >&2
return 1
fi
if ! mv "$tmp" "$f"; then
rm -f "$tmp"
echo "verdicts: mv failed for $f" >&2
return 1
fi
return 0
}

tl_verdict_read() { # <repo-root> <run-id> <slug> <gate>
local root="${1:-}" run="${2:-}" slug="${3:-}" gate="${4:-}" dir f
dir="$(tl_verdict_dir "$root" "$run" "$slug")" || return $?
case "$gate" in
test-first|ci-checks|runtime-verify|review) ;;
*) echo "verdicts: bad gate token '$gate'" >&2; return 2 ;;
esac
f="$dir/${gate}.json"
[ -f "$f" ] || return 1
cat "$f"
}

# tl_verdict_require_flip — 0 iff test-first, ci-checks, review are PASS
# and runtime-verify is PASS or SKIP with non-empty evidence.
tl_verdict_require_flip() { # <repo-root> <run-id> <slug>
local root="${1:-}" run="${2:-}" slug="${3:-}" g json st ev
for g in test-first ci-checks review; do
json="$(tl_verdict_read "$root" "$run" "$slug" "$g")" || return 1
st="$(printf '%s' "$json" | tl_json_field status)"
[ "$st" = "PASS" ] || return 1
done
json="$(tl_verdict_read "$root" "$run" "$slug" runtime-verify)" || return 1
st="$(printf '%s' "$json" | tl_json_field status)"
ev="$(printf '%s' "$json" | tl_json_field evidence)"
case "$st" in
PASS) return 0 ;;
SKIP) [ -n "$ev" ] && return 0; return 1 ;;
*) return 1 ;;
esac
}

# _tl_integration_ref <git-dir> — print a resolvable integration ref, or rc 1.
_tl_integration_ref() {
local d="$1" cand
if [ -n "${THROUGHLINE_INTEGRATION_BRANCH:-}" ]; then
git -C "$d" rev-parse -q --verify "${THROUGHLINE_INTEGRATION_BRANCH}^{commit}" >/dev/null 2>&1 \
&& { printf '%s\n' "$THROUGHLINE_INTEGRATION_BRANCH"; return 0; }
fi
if git -C "$d" symbolic-ref -q refs/remotes/origin/HEAD >/dev/null 2>&1; then
cand="$(git -C "$d" symbolic-ref --short refs/remotes/origin/HEAD)"
git -C "$d" rev-parse -q --verify "${cand}^{commit}" >/dev/null 2>&1 \
&& { printf '%s\n' "$cand"; return 0; }
fi
for cand in main master; do
git -C "$d" rev-parse -q --verify "${cand}^{commit}" >/dev/null 2>&1 \
&& { printf '%s\n' "$cand"; return 0; }
done
return 1
}

# tl_test_first_observe <git-dir> — 0 iff unique commits vs the integration
# merge-base contain a test(failing): subject before the first feat: / step(
# subject. Empty range / feat-only / reversed order → rc 1. Git-history only.
tl_test_first_observe() {
local d="${1:-}" integ mb subj seen_tf=0
[ -n "$d" ] && [ -d "$d" ] || { echo "verdicts: tl_test_first_observe needs a git dir" >&2; return 1; }
integ="$(_tl_integration_ref "$d")" || return 1
mb="$(git -C "$d" merge-base "$integ" HEAD 2>/dev/null)" || return 1
while IFS= read -r subj; do
case "$subj" in
'test(failing):'*) seen_tf=1 ;;
feat:*|step\(*)
[ "$seen_tf" -eq 1 ] || return 1
;;
esac
done < <(git -C "$d" log --format=%s --reverse "${mb}..HEAD" 2>/dev/null)
[ "$seen_tf" -eq 1 ]
}
Loading
Loading