Files
poimen/loop.sh
T

325 lines
10 KiB
Bash
Raw Normal View History

2026-08-05 12:04:04 -07:00
#!/usr/bin/env bash
# Agentic driver for the rust-agent-sys board.
#
# Per task, two `pi` passes:
# 1. coder — implements, runs the task's Verify command, reports
# 2. reviewer — re-verifies, audits false passes/traps, emits a markdown review
# The shell is the chain: the coder's report is fed into the reviewer's prompt.
# The shell also owns the gate rule and the Status writes, because those are
# mechanical and must not depend on a model choosing to comply.
#
# ./loop.sh run from the first unfinished task until done or blocked
# ./loop.sh --list show phase order + current status, run nothing
# ./loop.sh --dry-run list what would run, run nothing
# ./loop.sh --sync rewrite INDEX.md status cells from the task files
# ./loop.sh T0.2 T0.3 run only these, in the order given
#
# Resumable: state lives in the task files, not here. Rerun after a crash.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TASKS="$ROOT/tasks"
INDEX="$TASKS/INDEX.md"
REVIEWS="$ROOT/reviews"
GUIDE="$TASKS/rust-guide-line.md"
CRATE="$ROOT/poimen"
AGENTS="$ROOT/.pi/agents"
LOGS="$ROOT/.pi/logs"
# The golden-rule region of INDEX.md: board rules + progress tables.
GOLDEN_RULE_LINES="0-69"
CODER_TOOLS="read,write,edit,bash,grep,find,ls,hashline_edit"
REVIEWER_TOOLS="read,grep,find,ls,bash"
mkdir -p "$REVIEWS" "$LOGS"
# ---------------------------------------------------------------- board state
# Status cells carry emoji ("✅ Done", "⬜ Not started"), so match on substring.
status_of() { sed -n 's/^| *Status *| *\(.*[^ ]\) *|$/\1/p' "$1" | head -1; }
is_done() { case "$(status_of "$1")" in *Done*) return 0 ;; *) return 1 ;; esac; }
status_emoji() {
case "$(status_of "$1")" in
*Done*) printf '✅' ;;
*rogress*) printf '🟡' ;;
*lock*|*BLOCK*) printf '⛔' ;;
*) printf '⬜' ;;
esac
}
verdict_of() {
[ -f "$1" ] || { echo "no-review"; return; }
sed -n 's/^| *Verdict *| *\(.*[^ ]\) *|$/\1/p' "$1" | head -1
}
task_id() { basename "$1" | cut -d- -f1; }
phase_of() { echo "${1#T}" | cut -d. -f1; }
# Phase order. Numeric sort on phase then minor puts each gate task (highest
# minor in its phase) last, so sequential execution satisfies the board's
# "no phase starts until its predecessor's gate is green" rule for free.
ordered_tasks() {
find "$TASKS" -maxdepth 1 -name 'T*.md' \
| sed "s|.*/T||" \
| sort -t. -k1,1n -k2,2n \
| sed "s|^|$TASKS/T|"
}
file_for_id() { find "$TASKS" -maxdepth 1 -name "$1-*.md" | head -1; }
# The gate task of a phase is its highest-numbered task.
gate_of_phase() {
ordered_tasks | while read -r f; do
[ "$(phase_of "$(task_id "$f")")" = "$1" ] && echo "$f"
done | tail -1
}
# No phase starts until its predecessor's gate is green.
gate_blocks() {
local phase="$1" prev gate
[ "$phase" -eq 0 ] && return 1
prev=$((phase - 1))
gate="$(gate_of_phase "$prev")"
[ -n "$gate" ] || return 1
is_done "$gate" && return 1
echo "P$phase blocked: P$prev gate $(task_id "$gate") is '$(status_of "$gate")'"
return 0
}
# ------------------------------------------------------------- index mirroring
# Rewrite INDEX.md's status cells from the task files. The task file is the
# source of truth; a status changed in one and not the other is a lie.
sync_index() {
local map tmp before after
map="$(mktemp)"; tmp="$(mktemp)"
while read -r f; do
printf '%s %s\n' "$(task_id "$f")" "$(status_emoji "$f")"
done < <(ordered_tasks) > "$map"
before="$(wc -l < "$INDEX")"
awk -v mapfile="$map" '
BEGIN {
FS = "|"; OFS = "|"
while ((getline line < mapfile) > 0) {
split(line, a, " ")
st[a[1]] = a[2]
p = a[1]; sub(/^T/, "", p); sub(/\..*$/, "", p)
total[p]++
if (a[2] == "✅") done[p]++
else if (a[2] == "🟡") wip[p]++
else todo[p]++
gate[p] = a[1] # ordered input => last id in phase is the gate
alltotal++
}
}
# per-task row: | [T0.1](T0.1-foo.md) | Title | S | — | ⬜ |
$0 ~ /^\| \[T[0-9]+\.[0-9]+\]/ {
id = $2; sub(/^ *\[/, "", id); sub(/\].*$/, "", id)
if (id in st) { $6 = " " st[id] " "; print; next }
}
# phase summary row: | P0 — Foundations | 9 | 0 | 0 | 9 | ⬜ T0.9 |
$0 ~ /^\| P[0-8] / {
p = $2; sub(/^ *P/, "", p); sub(/ .*$/, "", p)
if (p in total) {
$3 = " " total[p] " "
$4 = " " (done[p] + 0) " "
$5 = " " (wip[p] + 0) " "
$6 = " " (todo[p] + 0) " "
$7 = " " st[gate[p]] " " gate[p] " "
print; next
}
}
# total row: | **Total** | **71** | **0** | **0** | **71** | 0/9 green |
$0 ~ /^\| \*\*Total\*\*/ {
gates = 0; green = 0; d = 0; w = 0; t = 0
for (p in total) {
gates++
if (st[gate[p]] == "✅") green++
d += done[p]; w += wip[p]; t += todo[p]
}
$3 = " **" alltotal "** "
$4 = " **" d "** "
$5 = " **" w "** "
$6 = " **" t "** "
$7 = " " green "/" gates " green "
print; next
}
{ print }
' "$INDEX" > "$tmp"
after="$(wc -l < "$tmp")"
if [ "$before" != "$after" ]; then
echo "[warn] index sync changed line count ($before -> $after); INDEX.md left alone"
rm -f "$map" "$tmp"
return 1
fi
mv "$tmp" "$INDEX"
rm -f "$map"
}
mark_done() {
local file="$1" tmp
tmp="$(mktemp)"
sed 's/^| *Status *|.*|$/| Status | ✅ Done |/' "$file" > "$tmp" && mv "$tmp" "$file"
sync_index
}
print_board() {
printf '%-7s %-16s %-18s %s\n' TASK STATUS VERDICT FILE
while read -r f; do
id="$(task_id "$f")"
printf '%-7s %-16s %-18s %s\n' \
"$id" "$(status_of "$f")" "$(verdict_of "$REVIEWS/$id-review.md")" "$(basename "$f")"
done < <(ordered_tasks)
}
# ------------------------------------------------------------------- prompts
coder_prompt() {
local id="$1" file="$2"
cat <<PROMPT
Implement task $id from the rust-agent-sys board.
Read $INDEX lines $GOLDEN_RULE_LINES FIRST, in full, before anything else. That
is the golden rule for this board and it outranks everything else in this
prompt. Obey its ordering rule, its Engineering Quality Rule, and its statement
that the Status field in each task file is the source of truth.
- Task file: $file
- Crate root: $CRATE (Cargo workspace; add new crates to its members list)
- Quality bar: $GUIDE — read before writing code.
Then:
1. Read the task file in full: Steps, Acceptance, Verify, False pass, Traps.
2. Write the tests named in the Verify section BEFORE the implementation. Watch
them fail for the right reason (missing type, not missing test file).
3. Implement the minimum code that satisfies Acceptance. Follow Steps exactly.
4. Run the exact Command line from the Verify section.
5. Do NOT edit $file or $INDEX. Status is written by the driver, not by you.
Report, in this order:
- files created/changed, one line each, and what each does
- the Verify Command line you ran, verbatim
- its full output and exit status, verbatim, whether it passed or failed
- anything in Steps you could not do, and why
PROMPT
}
reviewer_prompt() {
local id="$1" file="$2" report="$3"
cat <<PROMPT
Review task $id. Emit ONLY the markdown review document, nothing before or after.
- Board rules: $INDEX (lines $GOLDEN_RULE_LINES)
- Task file: $file
- Crate root: $CRATE
- Quality bar: $GUIDE
Re-run the Verify command yourself from $ROOT. Do not trust the report below.
Audit every False pass item and every Trap item named in the task file.
The coder reported:
--- BEGIN CODER REPORT ---
$(cat "$report")
--- END CODER REPORT ---
PROMPT
}
# --------------------------------------------------------------------- runner
run_task() {
local file="$1" id review report stamp log_c log_r tmp block vd
id="$(task_id "$file")"
review="$REVIEWS/$id-review.md"
stamp="$(date +%Y%m%dT%H%M%S)"
report="$LOGS/$id-coder-report.md"
log_c="$LOGS/$id-$stamp-coder.log"
log_r="$LOGS/$id-$stamp-reviewer.log"
if is_done "$file"; then
echo "[skip] $id already Done"
return 0
fi
if block="$(gate_blocks "$(phase_of "$id")")"; then
echo "[stop] $block"
return 1
fi
echo "[code] $id $(date '+%H:%M:%S') -> $log_c"
( cd "$ROOT" && pi --tools "$CODER_TOOLS" \
--append-system-prompt "$AGENTS/coder.md" \
--no-session -p "$(coder_prompt "$id" "$file")" ) 2>&1 | tee "$log_c"
cp "$log_c" "$report"
echo "[revw] $id $(date '+%H:%M:%S') -> $log_r"
( cd "$ROOT" && pi --tools "$REVIEWER_TOOLS" \
--append-system-prompt "$AGENTS/reviewer.md" \
--no-session -p "$(reviewer_prompt "$id" "$file" "$report")" ) 2>&1 | tee "$log_r"
# Strip an outer ``` fence if the reviewer wrapped the whole document.
tmp="$(mktemp)"
sed -e '1{/^```/d;}' -e '${/^```$/d;}' "$log_r" > "$tmp" && mv "$tmp" "$review"
vd="$(verdict_of "$review")"
case "$vd" in
*APPROVED*)
mark_done "$file"
echo "[ ok ] $id verdict=APPROVED status=$(status_of "$file")"
return 0
;;
*)
echo "[stop] $id verdict=${vd:-<none parsed>}"
echo " review: $review"
echo " logs: $log_c"
echo " $log_r"
return 1
;;
esac
}
# ----------------------------------------------------------------------- main
case "${1:-}" in
--list) print_board; exit 0 ;;
--sync) sync_index && echo "INDEX.md synced from task files"; exit $? ;;
--dry-run) ordered_tasks | while read -r f; do
is_done "$f" || echo "would run $(task_id "$f")"
done; exit 0 ;;
esac
command -v pi >/dev/null || { echo "pi not on PATH"; exit 127; }
# The TypeScript pi silently ignores unknown tools and lacks the Rust flags, so a
# run against it degrades instead of failing. --list-providers exists only on the
# Rust port; use it as the discriminator.
if ! pi --list-providers >/dev/null 2>&1; then
echo "pi at $(command -v pi) is not pi_agent_rust (reports: $(pi --version 2>&1 | head -1))"
echo "install the Rust port with install.sh --adopt, or point PATH at it"
exit 1
fi
[ -f "$AGENTS/coder.md" ] && [ -f "$AGENTS/reviewer.md" ] || {
echo "missing coder.md / reviewer.md in $AGENTS"; exit 1; }
if [ $# -gt 0 ]; then
for id in "$@"; do
f="$(file_for_id "$id")"
[ -n "$f" ] || { echo "no task file for $id"; exit 1; }
run_task "$f" || exit 1
done
echo "[done] requested tasks complete"
exit 0
fi
while read -r f; do
run_task "$f" || exit 1
done < <(ordered_tasks)
echo "[done] board complete"