diff --git a/README.md b/README.md index feb75a3..4e03826 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ reforge-seed # create repos from the manifest, seed the charter reforge-kickoff # file the agenda as issues reforge-role security-lead # one terminal per role … reforge-orchestrator # … or let one agent drive the whole run +reforge-harvest run-1 # export the run's record (issues, reviews, + # charter tree) as markdown + JSON for triage ``` Not on NixOS? The forge is `services.forgejo`, so declarative provisioning is @@ -73,8 +75,8 @@ the matching `REFORGE_*` variable. Extracted from the aiolabs machine config where it was first built and run. The engine is generic; the aiolabs runs (their real charter/agenda/manifest) -stay private. See `docs/reforge.md` for the follow-up ideas (markdown export -of a run's issues at close; Forgejo Actions as a CI gate ahead of review). +stay private. Remaining follow-up: Forgejo Actions as a CI gate ahead of +review (see `docs/architecture.md`). ## License diff --git a/docs/reforge.md b/docs/reforge.md index ecde06e..f2db38d 100644 --- a/docs/reforge.md +++ b/docs/reforge.md @@ -135,10 +135,17 @@ open Phase B — that switch is a log-worthy moment of the run. human opens Phase B when Phase A's signal is banked 5. compare reforge-compare iterate 4 ⇄ 5 until every targeted repo is IDENTICAL -6. harvest GAMEPLAN.md PR'd into charter; issues reviewed + triaged against - the real stack; **refine these instruction sources** (this doc, - the agent briefs, the agenda, the scripts) with what the run - taught — the simulation is meant to improve every iteration +6. harvest reforge-harvest [outdir] + (exports every issue, comment, PR and review per repo as + markdown + raw JSON, plus the charter tree — GAMEPLAN.md, + CONTRACTS.md — so the record is readable outside the forge); + commit the export next to the run config, then triage each + .md against the real stack: LIVE / FIXED / NA per finding, + still-live findings become real issues; **refine these + instruction sources** (this doc, the agent briefs, the agenda, + the scripts) with what the run taught — the simulation is meant + to improve every iteration, and the harvest is the next run's + agenda input 7. close reforge-reset backup (or go straight to the next run's reset, which archives too) ``` @@ -146,6 +153,12 @@ open Phase B — that switch is a log-worthy moment of the run. Archives: `/var/lib/forgejo-sandbox-archive/-.tar.gz`; restore any of them with `reforge-reset restore `. +**Harvest is not optional.** An archive preserves a run; only the harvest +makes it *act* on the real stack. A run whose findings stay in the tarball +has produced nothing — the aiolabs run #1 sat unharvested for three months +with two Critical findings live in deployed forks. Harvest before close, +every run. + ## Autonomous mode (an agent as orchestrator) Steps 3–5 (kickoff → rebuild → compare) can be driven by an agent itself diff --git a/scripts/reforge-harvest.sh b/scripts/reforge-harvest.sh new file mode 100755 index 0000000..9f2f99b --- /dev/null +++ b/scripts/reforge-harvest.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Reforge lifecycle step 6 (docs/reforge.md): export a run's full forge +# record — every issue with its comments, every PR with its reviews and +# review comments, per repo in the org, plus a tree snapshot of the charter +# repo (GAMEPLAN.md, CONTRACTS.md, ...) — as plain markdown + raw JSON. +# +# The archive tarball (reforge-reset backup) preserves the run; this makes +# it READABLE outside the forge, so findings can be triaged against the +# real stack, committed next to the run config, and fed into the next run's +# agenda. Without this step a run's value stays locked in a root-owned +# sqlite snapshot. +# +# reforge-harvest [name] [outdir] +# +# Defaults: name "run"; outdir $REFORGE_HARVEST_DIR/- +# (REFORGE_HARVEST_DIR defaults to ~/reforge-harvest). Read-only on the +# forge; safe to re-run at any point in a run. +# +# Output layout: +# INDEX.md counts per repo + what each file is +# .md rendered issues (with comments) + PRs (with reviews) +# raw/.issues.json API objects, one array per repo (issues incl. PRs) +# raw/.pulls.json +# raw/.comments.json {issue_number -> [comments]} +# raw/.reviews.json {pr_number -> [reviews with .comments]} +# charter-tree/ shallow working tree of the charter repo's main +set -euo pipefail + +FORGE_URL=${REFORGE_FORGE_URL:-http://localhost:3030} +ORG=${REFORGE_ORG:-sandbox-team} +ADMIN_USER=${REFORGE_ADMIN_USER:-sandbox-admin} +TOKENS_DIR=${REFORGE_TOKENS_DIR:-/var/lib/forgejo-sandbox/tokens} +TOKEN_FILE=${REFORGE_ADMIN_TOKEN_FILE:-$TOKENS_DIR/${ADMIN_USER}.token} +CHARTER_REPO=${REFORGE_CHARTER_REPO:-charter} + +NAME=${1:-run} +STAMP=$(date +%Y%m%d-%H%M%S) +OUT=${2:-${REFORGE_HARVEST_DIR:-$HOME/reforge-harvest}/$NAME-$STAMP} + +API="$FORGE_URL/api/v1" +TOKEN=$(cat "$TOKEN_FILE") +PAGE=50 + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +RESP="$WORK/resp" + +api() { # path -> echoes HTTP code, body in $RESP + curl -sS -o "$RESP" -w '%{http_code}' \ + -H "Authorization: token $TOKEN" "$API$1" +} + +# Follow ?page= until a page comes back empty; concatenate arrays. +api_all() { # path-with-query (no page param) -> JSON array on stdout + local path=$1 page=1 sep code + case $path in *\?*) sep='&' ;; *) sep='?' ;; esac + echo '[' + local first=1 + while :; do + code=$(api "$path${sep}limit=$PAGE&page=$page") + [ "$code" = 200 ] || { echo "harvest: GET $path page $page -> HTTP $code" >&2; cat "$RESP" >&2; exit 1; } + local n + n=$(jq 'length' <"$RESP") + [ "$n" -gt 0 ] || break + if [ $first = 1 ]; then first=0; else echo ','; fi + jq '.[]' <"$RESP" | jq -s '.[]' | jq -c . | paste -sd, - + [ "$n" -lt "$PAGE" ] && break + page=$((page + 1)) + done + echo ']' +} + +auth_url() { # name + echo "http://$ADMIN_USER:$TOKEN@${FORGE_URL#http://}/$ORG/$1.git" +} + +mkdir -p "$OUT/raw" +echo "→ reforge harvest '$NAME' → $OUT" +echo " forge $FORGE_URL org $ORG" +echo + +api_all "/orgs/$ORG/repos" | jq -r '.[].name' | sort >"$WORK/repos" +[ -s "$WORK/repos" ] || { echo "harvest: no repos in org $ORG" >&2; exit 1; } + +{ + echo "# Harvest — $NAME" + echo + echo "Exported $(date -Iseconds) from $FORGE_URL (org \`$ORG\`)." + echo "Read-only snapshot of the forge record; the authoritative copy is the" + echo "run archive (reforge-reset backup). Raw API objects are under \`raw/\`." + echo + echo "| repo | issues | open | PRs | merged | file |" + echo "|---|---|---|---|---|---|" +} >"$OUT/INDEX.md" + +while read -r repo; do + printf -- '-- %-16s' "$repo" + issues="$OUT/raw/$repo.issues.json" + pulls="$OUT/raw/$repo.pulls.json" + comments="$OUT/raw/$repo.comments.json" + reviews="$OUT/raw/$repo.reviews.json" + + api_all "/repos/$ORG/$repo/issues?state=all&type=issues" | jq . >"$issues" + api_all "/repos/$ORG/$repo/pulls?state=all" | jq . >"$pulls" + + # comments: issue-level comments cover both issues and PR conversation + { + echo '{' + first=1 + for n in $(jq -r '.[].number' "$issues" "$pulls" | sort -nu); do + c=$(api_all "/repos/$ORG/$repo/issues/$n/comments") + [ "$(echo "$c" | jq 'length')" -gt 0 ] || continue + if [ $first = 1 ]; then first=0; else echo ','; fi + printf '"%s": %s' "$n" "$c" + done + echo '}' + } | jq . >"$comments" + + # reviews (+ their inline comments) per PR + { + echo '{' + first=1 + for n in $(jq -r '.[].number' "$pulls" | sort -n); do + r=$(api_all "/repos/$ORG/$repo/pulls/$n/reviews") + [ "$(echo "$r" | jq 'length')" -gt 0 ] || continue + # attach inline comments to each review + r=$(echo "$r" | jq -c '.[]' | while read -r rev; do + id=$(echo "$rev" | jq -r .id) + rc=$(api_all "/repos/$ORG/$repo/pulls/$n/reviews/$id/comments") + echo "$rev" | jq --argjson c "$rc" '. + {comments: $c}' + done | jq -s .) + if [ $first = 1 ]; then first=0; else echo ','; fi + printf '"%s": %s' "$n" "$r" + done + echo '}' + } | jq . >"$reviews" + + # ── render ─────────────────────────────────────────────────────────── + jq -r --arg repo "$repo" --slurpfile comments "$comments" --slurpfile reviews "$reviews" \ + --slurpfile pulls "$pulls" ' + def md(s): (s // "" | gsub("\r"; "")); + def who(u): (u.login // "?"); + def when(t): (t // "" | .[0:16] | gsub("T"; " ")); + def cmts(n): ($comments[0][n | tostring] // []); + def revs(n): ($reviews[0][n | tostring] // []); + def render_comments(n): + (cmts(n) | if length == 0 then "" else + "\n#### Comments\n" + (map("- **" + who(.user) + "** (" + when(.created_at) + "):\n\n " + (md(.body) | gsub("\n"; "\n ")) + "\n") | join("\n")) end); + def render_reviews(n): + (revs(n) | if length == 0 then "" else + "\n#### Reviews\n" + (map( + "- **" + who(.user) + "** — " + (.state // "?") + " (" + when(.submitted_at) + ")" + + (if (md(.body) | length) > 0 then ":\n\n " + (md(.body) | gsub("\n"; "\n ")) else "" end) + "\n" + + ((.comments // []) | map(" - `" + (.path // "?") + "`: " + (md(.body) | gsub("\n"; " "))) | join("\n")) + "\n" + ) | join("\n")) end); + def entry(kind): + "### " + kind + " #" + (.number | tostring) + " " + .title + "\n\n" + + "_" + .state + (if (.pull_request.merged // .merged // false) then ", merged" else "" end) + + " · " + who(.user) + " · " + when(.created_at) + "_" + + (if (.labels // []) | length > 0 then " · labels: " + ((.labels | map(.name)) | join(", ")) else "" end) + "\n\n" + + md(.body) + "\n"; + "# " + $repo + "\n\n" + + "## Issues (" + (length | tostring) + ")\n\n" + + (map(entry("Issue") + render_comments(.number) + "\n---\n") | join("\n")) + + "\n## Pull requests (" + ($pulls[0] | length | tostring) + ")\n\n" + + ($pulls[0] | map(entry("PR") + render_comments(.number) + render_reviews(.number) + "\n---\n") | join("\n")) + ' "$issues" >"$OUT/$repo.md" + + ni=$(jq 'length' "$issues"); no=$(jq '[.[] | select(.state=="open")] | length' "$issues") + np=$(jq 'length' "$pulls"); nm=$(jq '[.[] | select(.merged==true)] | length' "$pulls") + echo "| $repo | $ni | $no | $np | $nm | [$repo.md]($repo.md) |" >>"$OUT/INDEX.md" + echo " issues $ni (open $no) PRs $np (merged $nm)" +done <"$WORK/repos" + +# ── charter tree: the run's synthesized deliverables live as files there ── +if grep -qx "$CHARTER_REPO" "$WORK/repos"; then + if git clone --quiet --depth 1 "$(auth_url "$CHARTER_REPO")" "$OUT/charter-tree" 2>/dev/null; then + rm -rf "$OUT/charter-tree/.git" + { + echo + echo "Charter repo tree (main, source-stripped): \`charter-tree/\` —" + find "$OUT/charter-tree" -type f | sed "s|$OUT/charter-tree/||" | sort | sed 's/^/ - /' + } >>"$OUT/INDEX.md" + echo "-- charter tree -> charter-tree/" + else + echo "-- charter tree: $CHARTER_REPO has no main yet, skipped" + fi +fi + +echo +echo "harvest: $OUT" +echo "next: triage .md against the real stack; commit the harvest next to" +echo " your run config; carry still-live findings into the next agenda."