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.
This commit is contained in:
83
lib/archive.sh
Normal file
83
lib/archive.sh
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# Branch archiving — archives previous run artifacts when the branch changes.
|
||||
# Preserves prd.json, progress.md, and contracts from the previous feature.
|
||||
#
|
||||
# Design: At the end of each run, snapshot_for_archive saves current artifacts
|
||||
# to .archive-staging/. On the next run, if the branch changed, check_archive
|
||||
# moves the snapshot to archive/ and cleans up. This avoids archiving the
|
||||
# WRONG artifacts (the new feature's) when prd.json has already been overwritten.
|
||||
|
||||
LAST_BRANCH_FILE="$LOOP_DIR/.last-branch"
|
||||
STAGING_DIR="$LOOP_DIR/.archive-staging"
|
||||
|
||||
# Snapshot current artifacts so they can be archived later if the branch changes.
|
||||
# Call this at the END of a successful run or before exit.
|
||||
snapshot_for_archive() {
|
||||
rm -rf "$STAGING_DIR"
|
||||
mkdir -p "$STAGING_DIR"
|
||||
|
||||
[ -f "$LOOP_DIR/prd.json" ] && cp "$LOOP_DIR/prd.json" "$STAGING_DIR/"
|
||||
[ -f "$LOOP_DIR/progress.md" ] && cp "$LOOP_DIR/progress.md" "$STAGING_DIR/"
|
||||
[ -d "$LOOP_DIR/contracts" ] && cp -r "$LOOP_DIR/contracts" "$STAGING_DIR/"
|
||||
}
|
||||
|
||||
# Check if we need to archive and do so if branch changed.
|
||||
# Reads the NEW branch from live prd.json and the OLD branch from the staging
|
||||
# snapshot (which was saved at the end of the previous run). This avoids the
|
||||
# bug where both branches read from the same (already-overwritten) prd.json.
|
||||
check_archive() {
|
||||
local current_branch
|
||||
current_branch=$(prd_branch_name 2>/dev/null)
|
||||
[ -z "$current_branch" ] && return
|
||||
|
||||
# Determine the previous branch from the staging snapshot (most reliable)
|
||||
# or fall back to .last-branch file
|
||||
local last_branch=""
|
||||
if [ -f "$STAGING_DIR/prd.json" ]; then
|
||||
if command -v jq &>/dev/null; then
|
||||
last_branch=$(jq -r '.branchName // empty' "$STAGING_DIR/prd.json" 2>/dev/null)
|
||||
else
|
||||
last_branch=$(LOOP_PRD="$STAGING_DIR/prd.json" python3 -c "
|
||||
import json, os
|
||||
print(json.load(open(os.environ['LOOP_PRD'])).get('branchName', ''), end='')
|
||||
" 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
[ -z "$last_branch" ] && [ -f "$LAST_BRANCH_FILE" ] && last_branch=$(cat "$LAST_BRANCH_FILE")
|
||||
|
||||
if [ -n "$last_branch" ] && [ "$last_branch" != "$current_branch" ]; then
|
||||
archive_run "$last_branch"
|
||||
fi
|
||||
|
||||
echo "$current_branch" > "$LAST_BRANCH_FILE"
|
||||
}
|
||||
|
||||
# Archive the previous run's staged artifacts (NOT current prd.json)
|
||||
archive_run() {
|
||||
local branch_name="$1"
|
||||
local feature_name
|
||||
feature_name=$(echo "$branch_name" | sed 's|.*/||')
|
||||
|
||||
local archive_dir="$LOOP_DIR/archive/$(date +%Y-%m-%d)-${feature_name}"
|
||||
mkdir -p "$archive_dir"
|
||||
|
||||
if [ -d "$STAGING_DIR" ]; then
|
||||
# Use the staged snapshot (correct artifacts from the previous run)
|
||||
cp -r "$STAGING_DIR"/* "$archive_dir/" 2>/dev/null || true
|
||||
rm -rf "$STAGING_DIR"
|
||||
else
|
||||
# Fallback: no snapshot exists (first run or upgrade from old version).
|
||||
# Current artifacts may belong to the new feature — archive what we have
|
||||
# but warn the user.
|
||||
log "WARNING: No archive snapshot found. Archiving current artifacts (may be from new feature)."
|
||||
[ -f "$LOOP_DIR/prd.json" ] && cp "$LOOP_DIR/prd.json" "$archive_dir/"
|
||||
[ -f "$LOOP_DIR/progress.md" ] && cp "$LOOP_DIR/progress.md" "$archive_dir/"
|
||||
[ -d "$LOOP_DIR/contracts" ] && cp -r "$LOOP_DIR/contracts" "$archive_dir/"
|
||||
fi
|
||||
|
||||
# Clean up old run's artifacts (progress.md, contracts — NOT prd.json which belongs to new feature)
|
||||
rm -f "$LOOP_DIR/progress.md"
|
||||
rm -rf "$LOOP_DIR/contracts"
|
||||
|
||||
log "Archived previous run to $archive_dir"
|
||||
}
|
||||
19
lib/hooks.sh
Normal file
19
lib/hooks.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Stop hook management for Claude Code loop continuation.
|
||||
#
|
||||
# NOTE: Hooks are currently no-ops. The loop uses `claude --print` (non-interactive),
|
||||
# which runs to completion and exits naturally — no Stop hook is needed to signal
|
||||
# iteration boundaries. The install/remove interface is preserved so that a future
|
||||
# interactive mode can be added without changing loop.sh's call sites.
|
||||
#
|
||||
# If interactive mode is added, the hook mechanism will need redesign: `kill -INT $PPID`
|
||||
# targets the hook runner's parent (Claude Code), not loop.sh. A sentinel-file or
|
||||
# named-pipe approach would be more reliable.
|
||||
|
||||
install_hooks() {
|
||||
: # no-op — see note above
|
||||
}
|
||||
|
||||
remove_hooks() {
|
||||
: # no-op — see note above
|
||||
}
|
||||
95
lib/prompt.sh
Normal file
95
lib/prompt.sh
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# Prompt assembly — composes the final prompt from base + mode overlay.
|
||||
# Injects runtime variables (scope budgets, current story, iteration count).
|
||||
|
||||
# Build the complete prompt for a given agent role and mode.
|
||||
# Usage: build_prompt "generator" "implement"
|
||||
# build_prompt "evaluator" "implement"
|
||||
build_prompt() {
|
||||
local role="$1" # generator | evaluator
|
||||
local mode="$2" # implement | explore | fix
|
||||
|
||||
local base_file="$LOOP_DIR/prompts/${role}/_base.md"
|
||||
local mode_file="$LOOP_DIR/prompts/${role}/${mode}.md"
|
||||
|
||||
local prompt=""
|
||||
|
||||
# Start with base prompt
|
||||
if [ -f "$base_file" ]; then
|
||||
prompt=$(cat "$base_file")
|
||||
else
|
||||
log "WARNING: Missing base prompt: $base_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Append mode-specific overlay
|
||||
if [ -f "$mode_file" ]; then
|
||||
prompt="${prompt}
|
||||
|
||||
---
|
||||
|
||||
$(cat "$mode_file")"
|
||||
else
|
||||
log "WARNING: Missing mode prompt: $mode_file"
|
||||
fi
|
||||
|
||||
# Inject runtime variables
|
||||
prompt=$(inject_variables "$prompt" "$mode")
|
||||
|
||||
printf '%s\n' "$prompt"
|
||||
}
|
||||
|
||||
# Replace template variables in prompt text
|
||||
inject_variables() {
|
||||
local text="$1"
|
||||
local mode="$2"
|
||||
|
||||
# Scope budgets from config
|
||||
local max_read max_write max_modify
|
||||
max_read=$(get_config_value ".scopeBudgets.${mode}.maxFilesToRead" "50")
|
||||
max_write=$(get_config_value ".scopeBudgets.${mode}.maxLinesToWrite" "500")
|
||||
max_modify=$(get_config_value ".scopeBudgets.${mode}.maxFilesToModify" "10")
|
||||
|
||||
text="${text//\{\{MAX_FILES_TO_READ\}\}/$max_read}"
|
||||
text="${text//\{\{MAX_LINES_TO_WRITE\}\}/$max_write}"
|
||||
text="${text//\{\{MAX_FILES_TO_MODIFY\}\}/$max_modify}"
|
||||
text="${text//\{\{MODE\}\}/$mode}"
|
||||
text="${text//\{\{ITERATION\}\}/$ITERATION}"
|
||||
text="${text//\{\{MAX_ITERATIONS\}\}/$MAX_ITERATIONS}"
|
||||
text="${text//\{\{LOOP_DIR\}\}/$LOOP_DIR}"
|
||||
text="${text//\{\{PROJECT_ROOT\}\}/$PROJECT_ROOT}"
|
||||
text="${text//\{\{CURRENT_STORY_ID\}\}/${CURRENT_STORY_ID:-unknown}}"
|
||||
text="${text//\{\{PRE_GENERATOR_SHA\}\}/${PRE_GENERATOR_SHA:-HEAD~1}}"
|
||||
|
||||
printf '%s\n' "$text"
|
||||
}
|
||||
|
||||
# Read a value from config.json with a default fallback
|
||||
get_config_value() {
|
||||
local path="$1"
|
||||
local default="$2"
|
||||
local config="$LOOP_DIR/config.json"
|
||||
|
||||
[ -f "$config" ] || { echo "$default"; return; }
|
||||
|
||||
if command -v jq &>/dev/null; then
|
||||
local val
|
||||
val=$(jq -r "$path // empty" "$config" 2>/dev/null)
|
||||
echo "${val:-$default}"
|
||||
else
|
||||
LOOP_CONFIG="$config" LOOP_PATH="$path" LOOP_DEFAULT="$default" python3 -c "
|
||||
import json, os
|
||||
d = json.load(open(os.environ['LOOP_CONFIG']))
|
||||
keys = os.environ['LOOP_PATH'].lstrip('.').split('.')
|
||||
for k in keys:
|
||||
d = d.get(k) if isinstance(d, dict) else None
|
||||
if d is None:
|
||||
break
|
||||
val = d if d is not None and d != {} else os.environ['LOOP_DEFAULT']
|
||||
# Normalize Python booleans to lowercase for shell compatibility
|
||||
if isinstance(val, bool):
|
||||
val = str(val).lower()
|
||||
print(val, end='')
|
||||
"
|
||||
fi
|
||||
}
|
||||
359
lib/state.sh
Normal file
359
lib/state.sh
Normal file
@@ -0,0 +1,359 @@
|
||||
#!/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
|
||||
}
|
||||
Reference in New Issue
Block a user