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.
360 lines
12 KiB
Bash
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
|
|
}
|