diff --git a/README.md b/README.md index 4e03826..feb75a3 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,6 @@ 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 @@ -75,8 +73,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. Remaining follow-up: Forgejo Actions as a CI gate ahead of -review (see `docs/architecture.md`). +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). ## License diff --git a/docs/reforge.md b/docs/reforge.md index f2db38d..ecde06e 100644 --- a/docs/reforge.md +++ b/docs/reforge.md @@ -135,17 +135,10 @@ 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 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 +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 7. close reforge-reset backup (or go straight to the next run's reset, which archives too) ``` @@ -153,12 +146,6 @@ 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/modules/reforge.nix b/modules/reforge.nix index 5026139..963bb87 100644 --- a/modules/reforge.nix +++ b/modules/reforge.nix @@ -49,8 +49,6 @@ let inherit tokensDir stateDir; configDir = toString cfg.configDir; agentsDir = if cfg.agentsDir == null then null else toString cfg.agentsDir; - # Seeded stack repos get the same approver whitelist as the working repo. - requiredApprovers = cfg.requiredApprovers; refsDir = if cfg.refsDir == null then null else toString cfg.refsDir; }; diff --git a/packages/reforge-scripts.nix b/packages/reforge-scripts.nix index 25d54de..eb6ca8d 100644 --- a/packages/reforge-scripts.nix +++ b/packages/reforge-scripts.nix @@ -37,7 +37,6 @@ configDir, agentsDir ? null, refsDir ? null, - requiredApprovers ? [ "security-lead" ], }: let @@ -89,7 +88,6 @@ stdenvNoCC.mkDerivation { --set-default REFORGE_CONFIG_DIR ${lib.escapeShellArg (toString configDir)} \ --set-default REFORGE_SETTINGS_DIR "$SETTINGS" \ --set-default REFORGE_AGENTS_DIR "$AGENTS" \ - --set-default REFORGE_REQUIRED_APPROVERS ${lib.escapeShellArg (lib.concatStringsSep " " requiredApprovers)} \ ${lib.optionalString ( refsDir != null ) "--set-default REFORGE_REFS_DIR ${lib.escapeShellArg (toString refsDir)}"} @@ -98,7 +96,7 @@ stdenvNoCC.mkDerivation { ''; meta = with lib; { - description = "reforge lifecycle CLI (seed/reset/compare/smoke/kickoff/role/orchestrator/fetch-targets/harvest)"; + description = "reforge lifecycle CLI (seed/reset/compare/smoke/kickoff/role/orchestrator/fetch-targets)"; mainProgram = "reforge-seed"; license = licenses.mit; }; diff --git a/scripts/reforge-harvest.sh b/scripts/reforge-harvest.sh deleted file mode 100755 index 9f2f99b..0000000 --- a/scripts/reforge-harvest.sh +++ /dev/null @@ -1,193 +0,0 @@ -#!/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." diff --git a/scripts/reforge-seed.sh b/scripts/reforge-seed.sh index e3dc4e1..5e2f08a 100644 --- a/scripts/reforge-seed.sh +++ b/scripts/reforge-seed.sh @@ -73,21 +73,12 @@ ensure_repo() { # name description fi } -# Approver whitelist: the module bakes reforge.requiredApprovers in as -# REFORGE_REQUIRED_APPROVERS (space-separated); the standalone default -# matches the module's default. Field name is singular (Forgejo API quirk). -APPROVERS=${REFORGE_REQUIRED_APPROVERS:-security-lead} -PROTECTION_JSON=$(jq -cn --arg a "$APPROVERS" \ - '{branch_name:"main",rule_name:"main",enable_push:false,required_approvals:1, - enable_approvals_whitelist:true, - approvals_whitelist_username:($a | split(" ") | map(select(length > 0))), - block_on_rejected_reviews:true,dismiss_stale_approvals:true}') - protect_main() { # name (same rule the module puts on the working repo) if [ "$(api GET "/repos/$ORG/$1/branch_protections/main")" = 404 ]; then - must "$(api POST "/repos/$ORG/$1/branch_protections" "$PROTECTION_JSON")" \ + must "$(api POST "/repos/$ORG/$1/branch_protections" \ + '{"branch_name":"main","rule_name":"main","enable_push":false,"required_approvals":1,"enable_approvals_whitelist":true,"approvals_whitelist_username":["security-lead"],"block_on_rejected_reviews":true,"dismiss_stale_approvals":true}')" \ "protect $ORG/$1 main" - echo " protected main (approvers: $APPROVERS)" + echo " protected main" fi }