diff --git a/dev/dev-agent.sh b/dev/dev-agent.sh index fae9ede..2c4f2e4 100755 --- a/dev/dev-agent.sh +++ b/dev/dev-agent.sh @@ -91,22 +91,6 @@ cleanup() { } trap cleanup EXIT -# STATE.MD helpers (must be defined before use at worktree setup) -write_state_entry() { - local target="${WORKTREE:-$REPO_ROOT}" - local state_file="${target}/STATE.md" - local today - today=$(date -u +%Y-%m-%d) - local description - description=$(echo "$ISSUE_TITLE" | sed 's/^feat:\s*//i;s/^fix:\s*//i;s/^refactor:\s*//i') - local line="- [${today}] ${description} (#${ISSUE})" - if [ ! -f "$state_file" ]; then - printf '# STATE.md — What %s currently is and does\n\n' "${PROJECT_NAME}" > "$state_file" - fi - echo "$line" >> "$state_file" - log "STATE.md: ${line}" -} -append_state_log() { write_state_entry; } # --- Log rotation --- if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then @@ -563,8 +547,6 @@ else git checkout -B "$BRANCH" "origin/${PRIMARY_BRANCH}" 2>/dev/null git submodule update --init --recursive 2>/dev/null || true - # Write STATE.md entry — included in the first commit, reads as done once PR merges - write_state_entry # Symlink lib node_modules from main repo (submodule init doesn't run npm install) for lib_dir in "$REPO_ROOT"/onchain/lib/*/; do @@ -925,9 +907,6 @@ do_merge() { if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then log "PR #${PR_NUMBER} merged!" - # Update STATE.md on primary branch (pull merged changes first) - (cd "$REPO_ROOT" && git checkout "${PRIMARY_BRANCH}" 2>/dev/null && git pull --ff-only origin "${PRIMARY_BRANCH}" 2>/dev/null) || true - append_state_log || log "WARNING: STATE.md update failed (non-fatal)" curl -sf -X DELETE \ -H "Authorization: token ${CODEBERG_TOKEN}" \ @@ -1140,12 +1119,7 @@ ${CI_ERROR_LOG:-No logs available. Use ci-debug.sh to query the pipeline.} fi if [ "$VERDICT" = "APPROVE" ]; then - # NOTE: STATE.md append moved to AFTER merge. - # Pushing before merge creates a new commit that dismisses - # the stale approval (dismiss_stale_approvals=true), causing - # 405 "not enough approvals" on merge. do_merge "$CURRENT_SHA" - # If merge succeeded, append_state_log was already called inside do_merge fi [ -n "$VERDICT" ] && break diff --git a/planner/planner-agent.sh b/planner/planner-agent.sh new file mode 100755 index 0000000..b62c107 --- /dev/null +++ b/planner/planner-agent.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# ============================================================================= +# planner-agent.sh — Rebuild STATE.md from git history, then gap-analyse +# +# Two-phase planner run: +# Phase 1: Rebuild STATE.md from git log + closed issues (compact snapshot) +# Phase 2: Compare STATE.md vs VISION.md, create backlog issues for gaps +# +# Usage: planner-agent.sh (no args — uses env vars from .env / env.sh) +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FACTORY_ROOT="$(dirname "$SCRIPT_DIR")" + +# shellcheck source=../lib/env.sh +source "$FACTORY_ROOT/lib/env.sh" + +LOG_FILE="$SCRIPT_DIR/planner.log" +CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-3600}" +MARKER_FILE="${PROJECT_REPO_ROOT}/.last-planner-sha" +STATE_FILE="${PROJECT_REPO_ROOT}/STATE.md" +VISION_FILE="${PROJECT_REPO_ROOT}/VISION.md" + +log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } + +# ── Preflight ──────────────────────────────────────────────────────────── +cd "$PROJECT_REPO_ROOT" +git fetch origin "${PRIMARY_BRANCH}" --quiet 2>/dev/null || true +git checkout "${PRIMARY_BRANCH}" --quiet 2>/dev/null || true +git pull --ff-only origin "${PRIMARY_BRANCH}" --quiet 2>/dev/null || true + +HEAD_SHA=$(git rev-parse HEAD) +log "--- Planner start (HEAD: ${HEAD_SHA:0:7}) ---" + +# ── Determine git log range ───────────────────────────────────────────── +if [ -f "$MARKER_FILE" ]; then + LAST_SHA=$(cat "$MARKER_FILE" 2>/dev/null | tr -d '[:space:]') + if git cat-file -e "$LAST_SHA" 2>/dev/null; then + GIT_RANGE="${LAST_SHA}..HEAD" + else + log "WARNING: marker SHA ${LAST_SHA:0:7} not found, using 30-day window" + GIT_RANGE="$(git log --format=%H --after='30 days ago' --reverse | head -1 2>/dev/null || echo HEAD~30)..HEAD" + fi +else + log "No marker file, using 30-day window" + GIT_RANGE="$(git log --format=%H --after='30 days ago' --reverse | head -1 2>/dev/null || echo HEAD~30)..HEAD" +fi + +GIT_LOG=$(git log "$GIT_RANGE" --oneline --no-merges 2>/dev/null || true) +MERGE_LOG=$(git log "$GIT_RANGE" --oneline --merges 2>/dev/null || true) +COMMIT_COUNT=$(echo "$GIT_LOG" | grep -c '.' || true) +log "Range: $GIT_RANGE ($COMMIT_COUNT commits)" + +if [ "$COMMIT_COUNT" -eq 0 ] && [ -f "$STATE_FILE" ]; then + log "No new commits since last run — skipping STATE.md rebuild" + # Still run gap analysis (vision or issues may have changed) +else + # ── Phase 1: Rebuild STATE.md ────────────────────────────────────────── + log "Phase 1: rebuilding STATE.md" + + CURRENT_STATE="" + [ -f "$STATE_FILE" ] && CURRENT_STATE=$(cat "$STATE_FILE") + + # Fetch recently closed issues for context + CLOSED_ISSUES=$(codeberg_api GET "/issues?state=closed&type=issues&limit=30&sort=updated&direction=desc" 2>/dev/null | \ + jq -r '.[] | "#\(.number) \(.title)"' 2>/dev/null || true) + + PHASE1_PROMPT="You are maintaining STATE.md — a compact factual snapshot of what ${PROJECT_NAME} currently is and does. + +## Current STATE.md +${CURRENT_STATE:-"(empty — create from scratch)"} + +## New commits since last snapshot +${GIT_LOG:-"(none)"} + +## Merge commits +${MERGE_LOG:-"(none)"} + +## Recently closed issues +${CLOSED_ISSUES:-"(none)"} + +## Task +Update STATE.md by merging the new commits/issues into the existing snapshot. +- Collapse redundant entries, merge related ones, discard superseded facts +- Output should read as a description of what the project IS, not a history of changes +- Plain bullets, no headers, no dates, no changelog framing +- Preserve issue/PR references (e.g. #42) on each line for traceability +- No more than 30 bullet points — be concise and factual +- If current STATE.md is empty, build the snapshot from scratch using the git log and issues + +Output ONLY the bullet list — no preamble, no markdown fences, no explanation." + + PHASE1_OUTPUT=$(timeout "$CLAUDE_TIMEOUT" claude -p "$PHASE1_PROMPT" \ + --model sonnet \ + 2>/dev/null) || { + log "ERROR: claude exited with code $? during phase 1" + exit 1 + } + + if [ -z "$PHASE1_OUTPUT" ]; then + log "ERROR: empty output from phase 1" + exit 1 + fi + + # Atomic write + TEMP_STATE=$(mktemp "${STATE_FILE}.XXXXXX") + printf '%s\n' "$PHASE1_OUTPUT" > "$TEMP_STATE" + mv "$TEMP_STATE" "$STATE_FILE" + + # Commit STATE.md if changed + if ! git diff --quiet "$STATE_FILE" 2>/dev/null; then + git add "$STATE_FILE" + git commit -m "chore: planner rebuild STATE.md" --quiet 2>/dev/null + git push origin "${PRIMARY_BRANCH}" --quiet 2>/dev/null || true + log "STATE.md committed and pushed" + fi + + # Update marker + echo "$HEAD_SHA" > "$MARKER_FILE" + log "Phase 1 done — STATE.md rebuilt ($(wc -l < "$STATE_FILE") lines)" +fi + +# ── Phase 2: Gap analysis ─────────────────────────────────────────────── +log "Phase 2: gap analysis" + +CURRENT_STATE=$(cat "$STATE_FILE" 2>/dev/null || true) +VISION="" +[ -f "$VISION_FILE" ] && VISION=$(cat "$VISION_FILE") + +if [ -z "$VISION" ]; then + log "No VISION.md found — skipping gap analysis" + log "--- Planner done ---" + exit 0 +fi + +# Fetch open issues (all labels) +OPEN_ISSUES=$(codeberg_api GET "/issues?state=open&type=issues&limit=50&sort=updated&direction=desc" 2>/dev/null || true) +if [ -z "$OPEN_ISSUES" ] || [ "$OPEN_ISSUES" = "null" ]; then + log "Failed to fetch open issues" + exit 1 +fi + +OPEN_SUMMARY=$(echo "$OPEN_ISSUES" | jq -r '.[] | "#\(.number) [\(.labels | map(.name) | join(","))] \(.title)"' 2>/dev/null || true) + +# Fetch vision-labeled issues specifically +VISION_ISSUES=$(echo "$OPEN_ISSUES" | jq -r '.[] | select(.labels | map(.name) | index("vision")) | "#\(.number) \(.title)\n\(.body)"' 2>/dev/null || true) + +PHASE2_PROMPT="You are the planner for ${CODEBERG_REPO}. Your job: find gaps between the project vision and current reality. + +## VISION.md (human-maintained goals) +${VISION} + +## STATE.md (current project snapshot) +${CURRENT_STATE} + +## Vision-labeled issues (goal anchors) +${VISION_ISSUES:-"(none)"} + +## All open issues +${OPEN_SUMMARY} + +## Task +Identify gaps — things implied by VISION.md that are neither reflected in STATE.md nor covered by an existing open issue. + +For each gap, output a JSON object (one per line, no array wrapper): +{\"title\": \"action-oriented title\", \"body\": \"problem statement + why it matters + rough approach\", \"depends\": [list of blocking issue numbers or empty]} + +## Rules +- Max 5 new issues — focus on highest-leverage gaps only +- Do NOT create issues for things already in STATE.md (already done) +- Do NOT create issues that overlap with ANY existing open issue, even partially +- Do NOT create issues about vision items, tech-debt, or in-progress work +- Each title should be a plain, action-oriented sentence +- Each body should explain: what's missing, why it matters for the vision, rough approach +- Reference blocking issues by number in depends array + +If there are no gaps, output exactly: NO_GAPS + +Output ONLY the JSON lines (or NO_GAPS) — no preamble, no markdown fences." + +PHASE2_OUTPUT=$(timeout "$CLAUDE_TIMEOUT" claude -p "$PHASE2_PROMPT" \ + --model sonnet \ + 2>/dev/null) || { + log "ERROR: claude exited with code $? during phase 2" + exit 1 +} + +if echo "$PHASE2_OUTPUT" | grep -q "NO_GAPS"; then + log "No gaps found — backlog is aligned with vision" + log "--- Planner done ---" + exit 0 +fi + +# ── Create issues from gap analysis ────────────────────────────────────── +# Find backlog label ID +BACKLOG_LABEL_ID=$(codeberg_api GET "/labels" 2>/dev/null | \ + jq -r '.[] | select(.name == "backlog") | .id' 2>/dev/null || true) + +CREATED=0 +while IFS= read -r line; do + [ -z "$line" ] && continue + # Skip non-JSON lines + echo "$line" | jq -e . >/dev/null 2>&1 || continue + + TITLE=$(echo "$line" | jq -r '.title') + BODY=$(echo "$line" | jq -r '.body') + DEPS=$(echo "$line" | jq -r '.depends // [] | map("#\(.)") | join(", ")') + + # Add dependency section if present + if [ -n "$DEPS" ] && [ "$DEPS" != "" ]; then + BODY="${BODY} + +## Depends on +${DEPS}" + fi + + # Create issue + CREATE_PAYLOAD=$(jq -nc --arg t "$TITLE" --arg b "$BODY" '{title:$t, body:$b}') + + # Add label if we found the backlog label ID + if [ -n "$BACKLOG_LABEL_ID" ]; then + CREATE_PAYLOAD=$(echo "$CREATE_PAYLOAD" | jq --argjson lid "$BACKLOG_LABEL_ID" '.labels = [$lid]') + fi + + RESULT=$(codeberg_api POST "/issues" -d "$CREATE_PAYLOAD" 2>/dev/null || true) + ISSUE_NUM=$(echo "$RESULT" | jq -r '.number // "?"' 2>/dev/null || echo "?") + log "Created #${ISSUE_NUM}: ${TITLE}" + CREATED=$((CREATED + 1)) + + [ "$CREATED" -ge 5 ] && break +done <<< "$PHASE2_OUTPUT" + +log "Phase 2 done — created $CREATED issues" +log "--- Planner done ---" diff --git a/planner/planner-poll.sh b/planner/planner-poll.sh new file mode 100755 index 0000000..4ec7a5c --- /dev/null +++ b/planner/planner-poll.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# ============================================================================= +# planner-poll.sh — Cron wrapper for planner-agent +# +# Runs weekly (or on-demand). Guards against concurrent runs and low memory. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FACTORY_ROOT="$(dirname "$SCRIPT_DIR")" + +# shellcheck source=../lib/env.sh +source "$FACTORY_ROOT/lib/env.sh" + +LOG_FILE="$SCRIPT_DIR/planner.log" +LOCK_FILE="/tmp/planner-poll.lock" + +log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; } + +# ── Lock ────────────────────────────────────────────────────────────────── +if [ -f "$LOCK_FILE" ]; then + LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null || true) + if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then + log "poll: planner running (PID $LOCK_PID)" + exit 0 + fi + rm -f "$LOCK_FILE" +fi +echo $$ > "$LOCK_FILE" +trap 'rm -f "$LOCK_FILE"' EXIT + +# ── Memory guard ────────────────────────────────────────────────────────── +AVAIL_MB=$(free -m | awk '/Mem:/{print $7}') +if [ "${AVAIL_MB:-0}" -lt 2000 ]; then + log "poll: skipping — only ${AVAIL_MB}MB available (need 2000)" + exit 0 +fi + +log "--- Planner poll start ---" + +# ── Run planner agent ───────────────────────────────────────────────────── +"$SCRIPT_DIR/planner-agent.sh" 2>&1 | while IFS= read -r line; do + log " $line" +done + +EXIT_CODE=${PIPESTATUS[0]} +if [ "$EXIT_CODE" -ne 0 ]; then + log "poll: planner-agent exited with code $EXIT_CODE" +fi + +log "--- Planner poll done ---"