Files
loop-loop/lib/state.sh
Sheldon Finlay 17e5eb707f feat: agent loop harness with Claude Code plugin support
Generator-evaluator architecture with iterative context-reset for
long-running coding tasks. Ships as a Claude Code plugin — install
with /plugin and use /agent-loop:init, /agent-loop:plan, /agent-loop:run.
2026-03-27 08:03:18 -04:00

360 lines
12 KiB
Bash

#!/bin/bash
# State management for prd.json and progress.md.
# Provides functions to query story status, update pass/fail, and append progress.
# Requires: jq (preferred) or python3 (fallback)
# --- PRD Validation ---
validate_prd() {
local prd="$LOOP_DIR/prd.json"
[ -f "$prd" ] || return 0 # no prd.json is handled elsewhere
if command -v jq &>/dev/null; then
if ! jq -e '.userStories | type == "array" and length > 0' "$prd" >/dev/null 2>&1; then
log "ERROR: prd.json is missing or has no userStories array"
exit 1
fi
else
LOOP_PRD="$prd" python3 -c "
import json, sys, os
d = json.load(open(os.environ['LOOP_PRD']))
stories = d.get('userStories', [])
if not isinstance(stories, list) or len(stories) == 0:
print('[loop] ERROR: prd.json is missing or has no userStories array', file=sys.stderr)
sys.exit(1)
"
fi
}
# --- PRD Queries ---
# Get the ID of the highest-priority incomplete story (skips blocked stories)
next_story_id() {
local prd="$LOOP_DIR/prd.json"
[ -f "$prd" ] || return 1
if command -v jq &>/dev/null; then
jq -r '[.userStories[] | select(.passes == false and .blocked != true)] | sort_by(.priority // 999) | .[0].id // empty' "$prd"
else
LOOP_PRD="$prd" python3 -c "
import json, os
stories = json.load(open(os.environ['LOOP_PRD']))['userStories']
pending = sorted([s for s in stories if not s['passes'] and not s.get('blocked')], key=lambda s: s.get('priority', 999))
print(pending[0]['id'] if pending else '', end='')
"
fi
}
# Check if all actionable stories are done (passed or blocked)
all_stories_pass() {
local prd="$LOOP_DIR/prd.json"
[ -f "$prd" ] || return 1
if command -v jq &>/dev/null; then
local actionable
actionable=$(jq '[.userStories[] | select(.passes == false and .blocked != true)] | length' "$prd")
[ "$actionable" -eq 0 ]
else
LOOP_PRD="$prd" python3 -c "
import json, sys, os
stories = json.load(open(os.environ['LOOP_PRD']))['userStories']
actionable = [s for s in stories if not s['passes'] and not s.get('blocked')]
sys.exit(0 if len(actionable) == 0 else 1)
"
fi
}
# Check if any stories are blocked
any_stories_blocked() {
local prd="$LOOP_DIR/prd.json"
[ -f "$prd" ] || return 1
if command -v jq &>/dev/null; then
local blocked
blocked=$(jq '[.userStories[] | select(.blocked == true)] | length' "$prd")
[ "$blocked" -gt 0 ]
else
LOOP_PRD="$prd" python3 -c "
import json, sys, os
stories = json.load(open(os.environ['LOOP_PRD']))['userStories']
blocked = [s for s in stories if s.get('blocked')]
sys.exit(0 if len(blocked) > 0 else 1)
"
fi
}
# Get total and completed story counts
story_counts() {
local prd="$LOOP_DIR/prd.json"
[ -f "$prd" ] || { echo "0/0"; return; }
if command -v jq &>/dev/null; then
local total passed
total=$(jq '.userStories | length' "$prd")
passed=$(jq '[.userStories[] | select(.passes == true)] | length' "$prd")
echo "${passed}/${total}"
else
LOOP_PRD="$prd" python3 -c "
import json, os
stories = json.load(open(os.environ['LOOP_PRD']))['userStories']
passed = sum(1 for s in stories if s['passes'])
print(f'{passed}/{len(stories)}', end='')
"
fi
}
# --- PRD Mutations ---
# Mark a story as passed
mark_story_pass() {
local story_id="$1"
local prd="$LOOP_DIR/prd.json"
if command -v jq &>/dev/null; then
local updated
updated=$(jq --arg id "$story_id" \
'if any(.userStories[]; .id == $id) then (.userStories[] | select(.id == $id)).passes = true else error("Story not found: \($id)") end' \
"$prd" 2>&1) || { log "WARNING: mark_story_pass failed for '$story_id'"; return 1; }
printf '%s\n' "$updated" > "${prd}.tmp" && mv "${prd}.tmp" "$prd"
else
LOOP_STORY_ID="$story_id" LOOP_PRD="$prd" python3 -c "
import json, pathlib, os, sys
p = pathlib.Path(os.environ['LOOP_PRD'])
d = json.loads(p.read_text())
story_id = os.environ['LOOP_STORY_ID']
found = False
for s in d['userStories']:
if s['id'] == story_id:
s['passes'] = True
found = True
break
if not found:
print(f'[loop] WARNING: mark_story_pass failed for {story_id!r}', file=sys.stderr)
sys.exit(1)
p.write_text(json.dumps(d, indent=2))
"
fi
}
# Mark a story as failed with rejection reason
mark_story_reject() {
local story_id="$1"
local reason="$2"
local prd="$LOOP_DIR/prd.json"
if command -v jq &>/dev/null; then
local updated
updated=$(jq --arg id "$story_id" --arg reason "$reason" \
'if any(.userStories[]; .id == $id) then (.userStories[] | select(.id == $id)) |= (.passes = false | .rejections = ((.rejections // 0) + 1) | .notes = ((.notes // "") + "\n[REJECTED] " + $reason)) else error("Story not found: \($id)") end' \
"$prd" 2>&1) || { log "WARNING: mark_story_reject failed for '$story_id'"; return 1; }
printf '%s\n' "$updated" > "${prd}.tmp" && mv "${prd}.tmp" "$prd"
else
# Pass reason via env var to avoid shell injection from evaluator output
LOOP_STORY_ID="$story_id" LOOP_REASON="$reason" LOOP_PRD="$prd" python3 -c "
import json, pathlib, os
p = pathlib.Path(os.environ['LOOP_PRD'])
d = json.loads(p.read_text())
story_id = os.environ['LOOP_STORY_ID']
reason = os.environ['LOOP_REASON']
for s in d['userStories']:
if s['id'] == story_id:
s['passes'] = False
s['rejections'] = s.get('rejections', 0) + 1
s['notes'] = s.get('notes', '') + '\n[REJECTED] ' + reason
break
p.write_text(json.dumps(d, indent=2))
"
fi
}
# Get rejection count for a story
story_rejections() {
local story_id="$1"
local prd="$LOOP_DIR/prd.json"
if command -v jq &>/dev/null; then
jq -r --arg id "$story_id" \
'.userStories[] | select(.id == $id) | .rejections // 0' "$prd"
else
LOOP_PRD="$prd" LOOP_STORY_ID="$story_id" python3 -c "
import json, os
stories = json.load(open(os.environ['LOOP_PRD']))['userStories']
story_id = os.environ['LOOP_STORY_ID']
for s in stories:
if s['id'] == story_id:
print(s.get('rejections', 0), end='')
break
"
fi
}
# Mark a story as blocked (needs human review, skip in future iterations)
mark_story_blocked() {
local story_id="$1"
local reason="$2"
local prd="$LOOP_DIR/prd.json"
if command -v jq &>/dev/null; then
local updated
updated=$(jq --arg id "$story_id" --arg reason "$reason" \
'if any(.userStories[]; .id == $id) then (.userStories[] | select(.id == $id)) |= (.blocked = true | .notes = ((.notes // "") + "\n[BLOCKED] " + $reason)) else error("Story not found: \($id)") end' \
"$prd" 2>&1) || { log "WARNING: mark_story_blocked failed for '$story_id'"; return 1; }
printf '%s\n' "$updated" > "${prd}.tmp" && mv "${prd}.tmp" "$prd"
else
LOOP_STORY_ID="$story_id" LOOP_REASON="$reason" LOOP_PRD="$prd" python3 -c "
import json, pathlib, os
p = pathlib.Path(os.environ['LOOP_PRD'])
d = json.loads(p.read_text())
story_id = os.environ['LOOP_STORY_ID']
reason = os.environ['LOOP_REASON']
for s in d['userStories']:
if s['id'] == story_id:
s['blocked'] = True
s['notes'] = s.get('notes', '') + '\n[BLOCKED] ' + reason
break
p.write_text(json.dumps(d, indent=2))
"
fi
}
# --- Progress ---
MAX_PROGRESS_ENTRIES=15
# Append a progress entry, rotating old entries to archive when limit is reached
append_progress() {
local entry="$1"
local progress="$LOOP_DIR/progress.md"
if [ ! -f "$progress" ]; then
cp "$LOOP_DIR/templates/progress.md.template" "$progress" 2>/dev/null || \
printf "# Progress\n\n## Codebase Patterns\n\n---\n\n## Session Log\n" > "$progress"
fi
printf "\n%s\n" "$entry" >> "$progress"
rotate_progress
}
# Archive old session log entries to keep progress.md from growing unbounded.
# Preserves the Codebase Patterns section and keeps only the last N entries.
rotate_progress() {
local progress="$LOOP_DIR/progress.md"
[ -f "$progress" ] || return
# Count session entries by counting "### " headers after the Session Log marker.
# Using headers instead of "---" separators avoids false positives from markdown
# code blocks or horizontal rules inside entries.
local entry_count
local session_start
session_start=$(grep -n '## Session Log' "$progress" | head -1 | cut -d: -f1)
if [ -z "$session_start" ]; then
return
fi
entry_count=$(tail -n +"$session_start" "$progress" | grep -c '^### ' 2>/dev/null || echo "0")
if [ "$entry_count" -le "$MAX_PROGRESS_ENTRIES" ]; then
return
fi
local archive="$LOOP_DIR/progress-archive.md"
if command -v python3 &>/dev/null; then
LOOP_PROGRESS="$progress" LOOP_ARCHIVE="$archive" \
LOOP_MAX_ENTRIES="$MAX_PROGRESS_ENTRIES" python3 -c "
import pathlib, os
progress = pathlib.Path(os.environ['LOOP_PROGRESS'])
archive = pathlib.Path(os.environ['LOOP_ARCHIVE'])
max_entries = int(os.environ['LOOP_MAX_ENTRIES'])
text = progress.read_text()
# Split at 'Session Log' header
if '## Session Log' not in text:
exit(0)
header, session_log = text.split('## Session Log', 1)
# Split entries by '---' separator
# parts[0] is the preamble between '## Session Log' and the first '---'
parts = session_log.split('\n---\n')
preamble = parts[0]
entries = parts[1:]
if len(entries) <= max_entries:
exit(0)
# Keep last max_entries, archive the rest
to_archive = entries[:-max_entries]
to_keep = entries[-max_entries:]
# Append archived entries
existing_archive = archive.read_text() if archive.exists() else '# Progress Archive\n'
existing_archive += '\n---\n'.join(to_archive)
archive.write_text(existing_archive)
# Rewrite progress with header + preamble + kept entries
progress.write_text(header + '## Session Log' + preamble + '\n---\n' + '\n---\n'.join(to_keep))
"
else
# Bash fallback: rotate session log entries with archiving.
# Uses awk to split on "### " entry headers for accurate counting
# (avoids false positives from "---" separators inside entries).
local session_start
session_start=$(grep -n '## Session Log' "$progress" | head -1 | cut -d: -f1)
[ -z "$session_start" ] && return
# Extract header (everything up to and including "## Session Log" line)
local header_content
header_content=$(head -n "$session_start" "$progress")
# Extract session content and split into entries by "### " headers
local session_content
session_content=$(tail -n +"$((session_start + 1))" "$progress")
# Count entries by "### " headers
local entry_count
entry_count=$(echo "$session_content" | grep -c '^### ' 2>/dev/null || echo "0")
[ "$entry_count" -le "$MAX_PROGRESS_ENTRIES" ] && return
# Find the line number (within session_content) of the Nth-from-last "### " header
local keep_from
keep_from=$(echo "$session_content" | grep -n '^### ' | tail -n "$MAX_PROGRESS_ENTRIES" | head -1 | cut -d: -f1)
[ -z "$keep_from" ] && return
# Archive older entries
local to_archive
to_archive=$(echo "$session_content" | head -n "$((keep_from - 1))")
if [ -n "$to_archive" ]; then
if [ -f "$archive" ]; then
printf '\n%s' "$to_archive" >> "$archive"
else
printf '# Progress Archive\n\n%s\n' "$to_archive" > "$archive"
fi
fi
# Keep recent entries
local kept_content
kept_content=$(echo "$session_content" | tail -n +"$keep_from")
printf '%s\n\n%s\n' "$header_content" "$kept_content" > "${progress}.tmp" \
&& mv "${progress}.tmp" "$progress"
fi
}
# Get the branch name from prd.json
prd_branch_name() {
local prd="$LOOP_DIR/prd.json"
[ -f "$prd" ] || return 1
if command -v jq &>/dev/null; then
jq -r '.branchName // empty' "$prd"
else
LOOP_PRD="$prd" python3 -c "
import json, os
print(json.load(open(os.environ['LOOP_PRD'])).get('branchName', ''), end='')
"
fi
}