Files
loop-loop/lib/archive.sh
Sheldon Finlay ecfbd0bb37 feat: worktree-based run isolation for parallel loops
Each /agent-loop:run now creates a git worktree for the feature branch
before generating stories. This provides full isolation:

- Multiple loops can run in parallel on different specs in the same project
- Main working directory stays on main, always available
- Each worktree has its own .loop/ state, tmux session, and branch
- Completed runs are archived to main's .loop/archive/ with runs.log

Changes:
- setup.sh: add --init-worktree mode for initializing worktree .loop/
- archive.sh: add archive_from_worktree() for cross-directory archiving
- loop.sh: replace branch checkout with validation (worktree is pre-checked-out)
- agents/planner.md: accept absolute path prefix for worktree .loop/ writes
- skills/run/SKILL.md: full rewrite — worktree creation in Phase 2, launch in
  Phase 3, archive on completion, .active-worktree tracking file
- skills/stories/SKILL.md: worktree-aware, defer to /run for full flow

Bump to 0.12.0.
2026-04-02 11:21:17 -04:00

230 lines
9.3 KiB
Bash

#!/bin/bash
# Run archiving — preserves prd.json, progress.md, and contracts from completed runs.
#
# Two archive triggers:
# 1. Branch change: check_archive detects a new branch and archives the staged snapshot.
# 2. Completed run: archive_and_reset is called by the /run skill when prd.json shows
# all stories passed (or the branch was deleted). This handles the common workflow
# of merging a feature branch back to main and starting a new feature.
#
# Archive layout:
# .loop/archive/
# runs.log — one-line-per-run index for quick lookup
# 2026-03-15-auth-system/ — full artifacts from that run
# prd.json, progress.md, contracts/
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
# Verify the archive received content before deleting originals
if ! find "$archive_dir" -maxdepth 1 -type f | read -r; then
log "WARNING: Archive directory $archive_dir is empty after copy — skipping deletion of originals to prevent data loss"
return
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"
append_runs_log "$branch_name" "$archive_dir"
log "Archived previous run to $archive_dir"
}
# Archive current run artifacts and reset for a new run.
# Called by the /run skill when a completed run is detected (all stories passed
# or the feature branch no longer exists). Unlike check_archive (which reads from
# staging), this archives the LIVE artifacts directly since we know they belong
# to the completed run.
archive_and_reset() {
local loop_dir="${1:-.loop}"
local prd="$loop_dir/prd.json"
[ -f "$prd" ] || return 0
# Read branch name from current prd.json
local branch_name=""
if command -v jq &>/dev/null; then
branch_name=$(jq -r '.branchName // empty' "$prd" 2>/dev/null)
elif command -v python3 &>/dev/null; then
branch_name=$(LOOP_PRD="$prd" python3 -c "
import json, os
print(json.load(open(os.environ['LOOP_PRD'])).get('branchName', ''), end='')
" 2>/dev/null)
fi
local feature_name
feature_name=$(echo "${branch_name:-unknown}" | sed 's|.*/||')
local archive_dir="$loop_dir/archive/$(date +%Y-%m-%d)-${feature_name}"
mkdir -p "$archive_dir"
# Archive live artifacts
[ -f "$prd" ] && cp "$prd" "$archive_dir/"
[ -f "$loop_dir/progress.md" ] && cp "$loop_dir/progress.md" "$archive_dir/"
[ -f "$loop_dir/progress-archive.md" ] && cp "$loop_dir/progress-archive.md" "$archive_dir/"
[ -d "$loop_dir/contracts" ] && cp -r "$loop_dir/contracts" "$archive_dir/"
# Verify archive has content before deleting originals
if ! find "$archive_dir" -maxdepth 1 -type f | read -r; then
echo "[archive] WARNING: Archive directory is empty — skipping reset to prevent data loss"
return 1
fi
append_runs_log "$branch_name" "$archive_dir"
# Reset run-specific files (keep config.json, init.sh, harness files)
rm -f "$loop_dir/prd.json"
rm -f "$loop_dir/progress.md"
rm -f "$loop_dir/progress-archive.md"
rm -rf "$loop_dir/contracts"
rm -rf "$loop_dir/.archive-staging"
rm -f "$loop_dir/.last-branch"
rm -f "$loop_dir/.verdict"
echo "[archive] Archived completed run to $archive_dir"
echo "[archive] .loop/ reset — ready for new stories"
}
# Archive a completed run from a worktree back to the main project's .loop/archive/.
# Called by the /run skill's completion handler after the loop finishes in a worktree.
#
# Usage: archive_from_worktree <worktree_loop_dir> <main_loop_dir>
# worktree_loop_dir: absolute path to the worktree's .loop/ (source)
# main_loop_dir: absolute path to the main project's .loop/ (destination)
archive_from_worktree() {
local wt_loop_dir="$1"
local main_loop_dir="$2"
local wt_prd="$wt_loop_dir/prd.json"
[ -f "$wt_prd" ] || { echo "[archive] WARNING: No prd.json in worktree — nothing to archive"; return 1; }
# Read branch name from worktree's prd.json
local branch_name=""
if command -v jq &>/dev/null; then
branch_name=$(jq -r '.branchName // empty' "$wt_prd" 2>/dev/null)
elif command -v python3 &>/dev/null; then
branch_name=$(LOOP_PRD="$wt_prd" python3 -c "
import json, os
print(json.load(open(os.environ['LOOP_PRD'])).get('branchName', ''), end='')
" 2>/dev/null)
fi
local feature_name
feature_name=$(echo "${branch_name:-unknown}" | sed 's|.*/||')
local archive_dir="$main_loop_dir/archive/$(date +%Y-%m-%d)-${feature_name}"
mkdir -p "$archive_dir"
# Copy artifacts from worktree
[ -f "$wt_prd" ] && cp "$wt_prd" "$archive_dir/"
[ -f "$wt_loop_dir/progress.md" ] && cp "$wt_loop_dir/progress.md" "$archive_dir/"
[ -f "$wt_loop_dir/progress-archive.md" ] && cp "$wt_loop_dir/progress-archive.md" "$archive_dir/"
[ -d "$wt_loop_dir/contracts" ] && cp -r "$wt_loop_dir/contracts" "$archive_dir/"
[ -d "$wt_loop_dir/triage" ] && cp -r "$wt_loop_dir/triage" "$archive_dir/"
# Verify archive has content
if ! find "$archive_dir" -maxdepth 1 -type f | read -r; then
echo "[archive] WARNING: Archive directory is empty — copy may have failed"
return 1
fi
append_runs_log "$branch_name" "$archive_dir"
echo "[archive] Archived worktree run to $archive_dir"
}
# Append a one-line summary to the runs log.
append_runs_log() {
local branch_name="$1"
local archive_dir="$2"
local runs_log
runs_log="$(dirname "$archive_dir")/runs.log"
# Read story counts from the archived prd.json
local total=0 passed=0 blocked=0
local archived_prd="$archive_dir/prd.json"
if [ -f "$archived_prd" ]; then
if command -v jq &>/dev/null; then
total=$(jq '.userStories | length' "$archived_prd" 2>/dev/null || echo 0)
passed=$(jq '[.userStories[] | select(.passes == true)] | length' "$archived_prd" 2>/dev/null || echo 0)
blocked=$(jq '[.userStories[] | select(.blocked == true)] | length' "$archived_prd" 2>/dev/null || echo 0)
elif command -v python3 &>/dev/null; then
eval "$(LOOP_PRD="$archived_prd" python3 -c "
import json, os
d = json.load(open(os.environ['LOOP_PRD']))
s = d.get('userStories', [])
print(f'total={len(s)} passed={sum(1 for x in s if x.get(\"passes\"))} blocked={sum(1 for x in s if x.get(\"blocked\"))}')
" 2>/dev/null)" || true
fi
fi
printf '%s %-30s %s/%s passed %s blocked\n' \
"$(date +%Y-%m-%d)" "${branch_name:-unknown}" "$passed" "$total" "$blocked" \
>> "$runs_log"
}