#!/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 }