feat: extract reforge engine into a standalone consumable flake

The forgejo-sandbox / reforge harness, lifted out of the machine config
into a host-agnostic, generic engine anyone can consume with Nix.

Two layers:
- engine (this repo) — nixosModules.reforge stands up the sandbox forge,
  provisions role accounts + tokens, enforces branch protection, and puts
  the reforge-* CLI + forgejo-mcp on PATH. Carries no project specifics.
- run config — per-project manifest/charter/agenda/issues an adopter fills
  in; scaffold one with the `reforge` flake template.

Portability fixes vs the in-config version:
- forgejo-mcp resolved from $REFORGE_MCP_BIN or PATH, never a named host
  (kills the nixosConfigurations.omni hardcode).
- all instance data + paths parameterized via REFORGE_* env, baked into the
  reforge-scripts wrappers from module options (configDir, agentsDir,
  refsDir, org, port, tokenOwner, ...).
- option namespace neutral (reforge.* not omni.packs.*); settings policies
  carry no absolute /etc/nixos paths.
- role briefs + orchestrator playbook genericized: all project specifics
  point at the charter; refs corpus optional.

Validated: nix flake check (eval) + builds of forgejo-mcp, reforge-scripts,
and a module-eval check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-18 22:50:49 +02:00
commit df0fd9a9ba
32 changed files with 2698 additions and 0 deletions

View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Reforge acceptance check (docs/reforge.md): byte-for-byte TREE diff of
# each sandbox repo's `main` against its declared target — the known
# working state — from $REFORGE_CONFIG_DIR/manifest.txt.
#
# Targets are fetched shallow from their real remotes (may require your ssh
# key, depending on the target URL); nothing is pushed or written anywhere.
# History is deliberately ignored: the run re-derives the tree through its
# own PR history, so only content parity counts.
#
# Exit 0 = every repo with a declared target is IDENTICAL.
#
# reforge-compare
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}
CONFIG_DIR=${REFORGE_CONFIG_DIR:?set REFORGE_CONFIG_DIR to your run config dir}
TOKEN_FILE=${REFORGE_ADMIN_TOKEN_FILE:-$TOKENS_DIR/${ADMIN_USER}.token}
TOKEN=$(cat "$TOKEN_FILE")
MANIFEST="$CONFIG_DIR/manifest.txt"
[ -r "$MANIFEST" ] || { echo "compare: no readable manifest at $MANIFEST" >&2; exit 1; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
auth_url() { # name
echo "http://$ADMIN_USER:$TOKEN@${FORGE_URL#http://}/$ORG/$1.git"
}
fail=0
mapfile -t ROWS < <(grep -Ev '^[[:space:]]*(#|$)' "$MANIFEST")
for row in "${ROWS[@]}"; do
IFS='|' read -r name _kind _upstream _base_ref target_url target_ref <<<"$row"
if [ "$target_url" = "-" ] || [ -z "$target_url" ]; then
echo "SKIP $name — no target declared in the manifest"
continue
fi
d="$WORK/$name"
git init --quiet "$d"
if ! git -C "$d" fetch --quiet --depth 1 "$(auth_url "$name")" main 2>/dev/null; then
echo "MISSING $name — no main in the sandbox (not seeded / not scaffolded yet)"
fail=1
continue
fi
sandbox_sha=$(git -C "$d" rev-parse FETCH_HEAD)
git -C "$d" fetch --quiet --depth 1 "$target_url" "$target_ref"
target_sha=$(git -C "$d" rev-parse FETCH_HEAD)
if git -C "$d" diff --quiet "$sandbox_sha" "$target_sha"; then
echo "IDENTICAL $name — matches $target_url @ $target_ref"
else
n=$(git -C "$d" diff --name-only "$sandbox_sha" "$target_sha" | wc -l)
summary=$(git -C "$d" diff --shortstat "$sandbox_sha" "$target_sha")
echo "DIFFERS $name$n files vs $target_ref:$summary"
fail=1
fi
done
echo
if [ "$fail" = 0 ]; then
echo "compare: every targeted repo is byte-for-byte identical to its target."
else
echo "compare: divergence remains (DIFFERS/MISSING above)."
fi
exit "$fail"

View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Reforge Phase B — local target-mirror fetcher (docs/reforge.md).
#
# Phase B opens end-state visibility. But role sessions AND the orchestrator
# are policy-barred from reaching the real target remotes, so the targets
# cannot be fetched from inside a run. This OPERATOR step makes each
# manifest target available as a LOCAL, SOURCE-STRIPPED copy roles can read
# while converging.
#
# Safety: after each clone ALL git metadata is deleted (`rm -rf .git`). The
# copies have NO remote, NO cached credentials, and NO push path — plain
# read-only trees. Nothing this produces can push to, or otherwise affect,
# the real target repos. The only network touch is the read-only clone,
# with your credentials (operator step by design).
#
# reforge-fetch-targets # fetch/refresh all
# REFORGE_TARGETS_DIR=/some/path reforge-fetch-targets
#
# Roles then read $REFORGE_TARGETS_DIR/<repo> (default ~/reforge-targets/<repo>)
# as the end-state; provenance is in each <repo>/.REFORGE_TARGET_SOURCE.txt.
set -euo pipefail
CONFIG_DIR=${REFORGE_CONFIG_DIR:?set REFORGE_CONFIG_DIR to your run config dir}
MANIFEST=${REFORGE_MANIFEST:-$CONFIG_DIR/manifest.txt}
DEST=${REFORGE_TARGETS_DIR:-$HOME/reforge-targets}
# Never hang on an interactive prompt: no password/host-key questions, fail
# fast on an unreachable host, auto-accept a new host key.
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15}"
export GIT_TERMINAL_PROMPT=0
[ -r "$MANIFEST" ] || { echo "no readable manifest at $MANIFEST" >&2; exit 1; }
# rm that first restores write bits (copies are made read-only below).
# NOTE: must always return 0 — a non-zero return here would trip `set -e`.
safe_rm() { if [ -e "$1" ]; then chmod -R u+w "$1" 2>/dev/null || true; rm -rf "$1"; fi; return 0; }
echo "→ reforge target mirrors → $DEST"
echo " source-stripped: .git removed after clone; no remote, no push path."
echo
mkdir -p "$DEST"
fetched=0 skipped=0 failed=0
mapfile -t ROWS < <(grep -Ev '^[[:space:]]*(#|$)' "$MANIFEST")
echo "parsed ${#ROWS[@]} manifest rows; ssh: $GIT_SSH_COMMAND"
echo
err="$DEST/.clone-err.log"; : > "$err"
for row in "${ROWS[@]}"; do
IFS='|' read -r name _kind _upstream _base_ref target_url target_ref <<<"$row"
if [ "$target_url" = "-" ] || [ -z "$target_url" ]; then
printf 'SKIP %-14s no target declared\n' "$name"
skipped=$((skipped+1)); continue
fi
tmp="$DEST/.tmp-$name"
dest="$DEST/$name"
safe_rm "$tmp"
printf 'CLONE %-14s %s @ %s\n' "$name" "$target_url" "$target_ref"
if ! git clone --quiet --depth 1 --branch "$target_ref" "$target_url" "$tmp" 2>>"$err"; then
# fallback: full clone then checkout (handles non-branch refs)
safe_rm "$tmp"
if ! git clone --quiet "$target_url" "$tmp" 2>>"$err"; then
printf 'FAIL %-14s clone failed — last error:\n' "$name"
tail -n 3 "$err" | sed 's/^/ /'
failed=$((failed+1)); continue
fi
git -C "$tmp" checkout --quiet "$target_ref" 2>>"$err" || true
fi
sha=$(git -C "$tmp" rev-parse HEAD)
# provenance recorded BEFORE we strip git metadata
cat > "$tmp/.REFORGE_TARGET_SOURCE.txt" <<EOF
repo: $name
target_url: $target_url
target_ref: $target_ref
commit: $sha
note: Source-stripped local mirror for reforge Phase B convergence.
No git remote remains. Read-only. DO NOT push anywhere.
EOF
# STRIP ALL SOURCE INFO — the guarantee: no remote, no creds, no push path.
rm -rf "$tmp/.git"
safe_rm "$dest"
mv "$tmp" "$dest"
chmod -R a-w "$dest" 2>/dev/null || true # read-only reference tree
printf 'OK %-14s %s → %s (git stripped)\n' "$name" "${sha:0:12}" "$dest"
fetched=$((fetched+1))
done
echo
echo "done: $fetched fetched, $skipped skipped, $failed failed → $DEST"
# hard verification that nothing retained a source/remote
if find "$DEST" -maxdepth 3 -name .git -print 2>/dev/null | grep -q .; then
echo "WARNING: a .git directory remains under $DEST — investigate before using." >&2
exit 2
fi
echo "verified: no .git metadata anywhere under $DEST (no push path exists)."
[ "$failed" = 0 ] || { echo "note: some clones failed — re-run after checking access." >&2; exit 1; }

View file

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Reforge lifecycle step 3 (docs/reforge.md): file the run agenda as issues
# in the relevant sandbox repos. The issue rows live in
# $REFORGE_CONFIG_DIR/issues.tsv (one per line):
#
# repo|title|body
#
# ('#' and blank lines are ignored; body is single-line markdown.) Keep
# this file as the machine-readable twin of your agenda.md — titles should
# carry an agenda item id (e.g. "[A1] ...") for traceability.
#
# Idempotent: an issue whose exact title already exists in the repo (any
# state) is skipped, so re-running converges.
#
# reforge-kickoff
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}
CONFIG_DIR=${REFORGE_CONFIG_DIR:?set REFORGE_CONFIG_DIR to your run config dir}
TOKEN_FILE=${REFORGE_ADMIN_TOKEN_FILE:-$TOKENS_DIR/${ADMIN_USER}.token}
API="$FORGE_URL/api/v1"
TOKEN=$(cat "$TOKEN_FILE")
ISSUES_FILE="$CONFIG_DIR/issues.tsv"
[ -r "$ISSUES_FILE" ] || { echo "kickoff: no readable issues file at $ISSUES_FILE" >&2; exit 1; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
RESP="$WORK/resp"
api() { # method path [json-body] -> echoes HTTP code, body in $RESP
local method=$1 path=$2 data=${3:-}
local args=(
-sS -o "$RESP" -w '%{http_code}' -X "$method"
-H "Authorization: token $TOKEN"
-H 'Content-Type: application/json'
)
if [ -n "$data" ]; then args+=(--data "$data"); fi
curl "${args[@]}" "$API$path"
}
filed=0 skipped=0 failed=0
mapfile -t ROWS < <(grep -Ev '^[[:space:]]*(#|$)' "$ISSUES_FILE")
current_repo=
for row in "${ROWS[@]}"; do
IFS='|' read -r repo title body <<<"$row"
# one issue-list fetch per repo (titles for the idempotency check)
if [ "$repo" != "$current_repo" ]; then
current_repo=$repo
if [ "$(api GET "/repos/$ORG/$repo/issues?state=all&type=issues&limit=50")" = 200 ]; then
jq -r '.[].title' <"$RESP" >"$WORK/titles" || : >"$WORK/titles"
else
: >"$WORK/titles"
fi
fi
if grep -qxF "$title" "$WORK/titles"; then
skipped=$((skipped + 1))
continue
fi
payload=$(jq -n --arg t "$title" --arg b "$body" '{title: $t, body: $b}')
code=$(api POST "/repos/$ORG/$repo/issues" "$payload")
if [ "$code" = 201 ]; then
echo " filed $repo: $title"
filed=$((filed + 1))
else
echo " FAILED $repo: $title (HTTP $code)" >&2
cat "$RESP" >&2
failed=$((failed + 1))
fi
done
echo
echo "kickoff: $filed filed, $skipped already present, $failed failed."
[ "$failed" = 0 ]

View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Launch the reforge ORCHESTRATOR session (docs/reforge.md, autonomous
# mode). The agent drives the whole run: schedules role turns, routes work
# by issue/PR number, judges convergence, checkpoints — the job the human
# operator otherwise does. It launches fresh headless role sessions (via
# reforge-role) and coordinates only through the forge; it never reviews or
# implements itself, and the review gate stays inviolable (its policy denies
# curl/push/reset — it can only merge already-approved PRs via MCP).
#
# reforge-orchestrator [agent args...]
#
# Interactive (watch it work): reforge-orchestrator
# Then tell it e.g. "Drive Phase A to completion, stopping at the A->B
# boundary." For unattended operation, add your own -p/--permission-mode
# args or wrap in a loop — but read the risk notes in docs/reforge.md first
# (nested sessions, cost, model-nondeterminism per run).
#
# Prepares $REFORGE_TEAM_DIR/orchestrator/ fresh each launch:
# .claude/settings.json <- $REFORGE_SETTINGS_DIR/orchestrator-settings.json
# .mcp.json forge MCP wired to the ADMIN token
# CLAUDE.md <- $REFORGE_AGENTS_DIR/orchestrator.md (the playbook)
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}
AGENTS_DIR=${REFORGE_AGENTS_DIR:?set REFORGE_AGENTS_DIR to the agent briefs dir}
SETTINGS_DIR=${REFORGE_SETTINGS_DIR:?set REFORGE_SETTINGS_DIR to the permission-policy dir}
TEAM_DIR=${REFORGE_TEAM_DIR:-$HOME/sandbox-team}
AGENT_CMD=${REFORGE_AGENT_CMD:-claude}
TOKEN_FILE=${REFORGE_ADMIN_TOKEN_FILE:-$TOKENS_DIR/${ADMIN_USER}.token}
if [ ! -r "$TOKEN_FILE" ]; then
echo "reforge-orchestrator: no readable admin token at $TOKEN_FILE — is the sandbox provisioned?" >&2
exit 1
fi
MCP_BIN=${REFORGE_MCP_BIN:-$(command -v forgejo-mcp || true)}
if [ -z "$MCP_BIN" ]; then
echo "reforge-orchestrator: no forgejo-mcp — set REFORGE_MCP_BIN or put forgejo-mcp on PATH" >&2
exit 1
fi
WORKDIR="$TEAM_DIR/orchestrator"
mkdir -p "$WORKDIR/.claude"
cp "$SETTINGS_DIR/orchestrator-settings.json" "$WORKDIR/.claude/settings.json"
sed -e "s|@FORGE_URL@|$FORGE_URL|g" \
-e "s|@ORG@|$ORG|g" \
-e "s|@TOKENS_DIR@|$TOKENS_DIR|g" \
"$AGENTS_DIR/orchestrator.md" >"$WORKDIR/CLAUDE.md"
cat >"$WORKDIR/.mcp.json" <<EOF
{
"mcpServers": {
"forgejo-sandbox": {
"type": "stdio",
"command": "bash",
"args": [
"-c",
"FORGEJO_ACCESS_TOKEN=\$(cat $TOKEN_FILE) FORGEJO_URL=$FORGE_URL exec $MCP_BIN --transport stdio"
]
}
}
}
EOF
echo "→ launching orchestrator session in $WORKDIR"
echo " (it drives role turns via reforge-role; gate stays enforced)"
cd "$WORKDIR"
exec "$AGENT_CMD" "$@"

93
scripts/reforge-reset.sh Normal file
View file

@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Run lifecycle for the reforge sandbox (docs/reforge.md):
#
# reforge-reset backup [name] cold archive of the current run
# reforge-reset reset [name] archive, then wipe to zero and reprovision
# reforge-reset restore <tarball> put an archived run back
#
# Archives land in /var/lib/forgejo-sandbox-archive/<name>-<stamp>.tar.gz
# (root-owned, 0600). They capture the FULL run record: the forge state
# (repos, issues, PRs, reviews, users — /var/lib/forgejo) plus the
# credentials (/var/lib/forgejo-sandbox), taken cold (services stopped) so
# the sqlite snapshot is consistent.
#
# `reset` always archives first — a run is never destroyed, only closed.
# After a reset the provisioning oneshot recreates users, tokens, org and
# the working repo from zero; re-seed the stack repos with reforge-seed.
#
# Needs root (stops services, wipes /var/lib) — re-execs under sudo.
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then exec sudo "$0" "$@"; fi
ARCHIVE_DIR=/var/lib/forgejo-sandbox-archive
STATE_DIRS=(forgejo forgejo-sandbox) # relative to /var/lib
STAMP=$(date +%Y%m%d-%H%M%S)
usage() {
echo "usage: $0 backup [name] | reset [name] | restore <tarball>" >&2
exit 1
}
confirm() { # prompt
local reply
read -r -p "$1 [y/N] " reply
case $reply in y | Y) ;; *)
echo "aborted."
exit 1
;;
esac
}
# forgejo-secrets is included: it's a RemainAfterExit oneshot, so after a
# wipe it still reads "active" and Requires= won't re-run it — forgejo then
# fails on the missing instance secrets. Explicitly restarting it
# regenerates them (it converges: only writes files that are absent).
stop_stack() { systemctl stop reforge-provision forgejo forgejo-secrets; }
start_stack() {
# Recreate the /var/lib skeletons + perms (forgejo's own dirs and the
# sandbox token dir are both tmpfiles-managed).
systemd-tmpfiles --create
systemctl restart forgejo-secrets
systemctl start forgejo
# oneshot with RemainAfterExit: restart re-runs it; converges on existing
# state, recreates users/tokens/org/working-repo on zero.
systemctl restart reforge-provision
}
do_backup() { # name
local out="$ARCHIVE_DIR/${1:-run}-$STAMP.tar.gz"
mkdir -p "$ARCHIVE_DIR"
stop_stack
tar -C /var/lib -czf "$out" "${STATE_DIRS[@]}"
chmod 600 "$out"
echo "archived -> $out"
}
case ${1:-} in
backup)
do_backup "${2:-run}"
start_stack
;;
reset)
confirm "Archive then WIPE the sandbox forge (repos, issues, PRs, users)?"
do_backup "${2:-run}"
rm -rf /var/lib/forgejo /var/lib/forgejo-sandbox
start_stack
echo "reset: fresh instance provisioned."
echo "next: reforge-seed (as your normal user)"
;;
restore)
[ -n "${2:-}" ] && [ -f "${2:-}" ] || usage
confirm "Replace the CURRENT sandbox state with $2?"
stop_stack
rm -rf /var/lib/forgejo /var/lib/forgejo-sandbox
tar -C /var/lib -xzf "$2"
start_stack
echo "restored $2 (tokens/passwords from that run are live again)"
;;
*)
usage
;;
esac

91
scripts/reforge-role.sh Normal file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Launch an agent session as a reforge role (docs/reforge.md,
# docs/architecture.md). One terminal per role.
#
# reforge-role <role> [agent args...]
#
# Prepares $REFORGE_TEAM_DIR/<role>/ as the session workdir, fresh on every
# launch (policy and briefs come from the engine / your config — hand-edits
# to the runtime copies are overwritten):
#
# .claude/settings.json <- $REFORGE_SETTINGS_DIR/role-settings.json
# .mcp.json forge MCP wired to the ROLE's token — same server
# name as any admin-scope instance, so the role
# token SHADOWS admin in this dir
# CLAUDE.md role brief <- $REFORGE_AGENTS_DIR/{common,<role>}.md
#
# Isolation is best-effort (same unix user): the policy denies the obvious
# escapes (ssh, git remotes, non-localhost clones/pushes, curl, other MCP
# servers) and GIT_SSH_COMMAND=false blocks git's internal ssh — but this is
# a discipline boundary, not a security one.
set -euo pipefail
FORGE_URL=${REFORGE_FORGE_URL:-http://localhost:3030}
ORG=${REFORGE_ORG:-sandbox-team}
TOKENS_DIR=${REFORGE_TOKENS_DIR:-/var/lib/forgejo-sandbox/tokens}
AGENTS_DIR=${REFORGE_AGENTS_DIR:?set REFORGE_AGENTS_DIR to the agent briefs dir}
SETTINGS_DIR=${REFORGE_SETTINGS_DIR:?set REFORGE_SETTINGS_DIR to the permission-policy dir}
TEAM_DIR=${REFORGE_TEAM_DIR:-$HOME/sandbox-team}
AGENT_CMD=${REFORGE_AGENT_CMD:-claude}
usage() {
echo "usage: reforge-role <role> [agent args...]" >&2
echo -n "roles:" >&2
for f in "$AGENTS_DIR"/*.md; do
b=$(basename "$f" .md)
case $b in common | orchestrator) ;; *) echo -n " $b" >&2 ;; esac
done
echo >&2
exit 1
}
ROLE=${1:-}
[ -n "$ROLE" ] || usage
shift
BRIEF="$AGENTS_DIR/$ROLE.md"
case $ROLE in common | orchestrator) usage ;; esac
[ -f "$BRIEF" ] || usage
TOKEN_FILE="$TOKENS_DIR/$ROLE.token"
if [ ! -r "$TOKEN_FILE" ]; then
echo "reforge-role: no readable token at $TOKEN_FILE — is the sandbox provisioned (and does the role exist)?" >&2
exit 1
fi
# forgejo-mcp binary: explicit override, else whatever is on PATH (the
# NixOS module installs it). No named-host / flake resolution.
MCP_BIN=${REFORGE_MCP_BIN:-$(command -v forgejo-mcp || true)}
if [ -z "$MCP_BIN" ]; then
echo "reforge-role: no forgejo-mcp — set REFORGE_MCP_BIN or put forgejo-mcp on PATH" >&2
exit 1
fi
WORKDIR="$TEAM_DIR/$ROLE"
mkdir -p "$WORKDIR/.claude"
cp "$SETTINGS_DIR/role-settings.json" "$WORKDIR/.claude/settings.json"
sed -e "s|@ROLE@|$ROLE|g" \
-e "s|@FORGE_URL@|$FORGE_URL|g" \
-e "s|@FORGE_HOST@|${FORGE_URL#*://}|g" \
-e "s|@ORG@|$ORG|g" \
-e "s|@TOKENS_DIR@|$TOKENS_DIR|g" \
"$AGENTS_DIR/common.md" "$BRIEF" >"$WORKDIR/CLAUDE.md"
cat >"$WORKDIR/.mcp.json" <<EOF
{
"mcpServers": {
"forgejo-sandbox": {
"type": "stdio",
"command": "bash",
"args": [
"-c",
"FORGEJO_ACCESS_TOKEN=\$(cat $TOKEN_FILE) FORGEJO_URL=$FORGE_URL exec $MCP_BIN --transport stdio"
]
}
}
}
EOF
echo "→ launching $ROLE session in $WORKDIR"
cd "$WORKDIR"
exec "$AGENT_CMD" "$@"

151
scripts/reforge-seed.sh Normal file
View file

@ -0,0 +1,151 @@
#!/usr/bin/env bash
# Seed the sandbox forge for a reforge run (docs/reforge.md). The repo set,
# pinned bases and declared targets live in the run manifest,
# $REFORGE_CONFIG_DIR/manifest.txt:
#
# - kind=fork -> clone of the upstream at base_ref (branch, tag, or
# commit SHA; full history), pushed as `main`, then
# protected (no direct push, security-lead approval
# required).
# - kind=original -> empty placeholder repo, unprotected until a first
# scaffold exists.
# - charter -> in-forge copy of $REFORGE_CONFIG_DIR/charter.md
# (README.md) + agenda.md (AGENDA.md).
#
# Targets are never pushed into the sandbox — the acceptance diff against
# them is reforge-compare.
#
# Idempotent: every step is check-before-create / skip-if-pushed, so
# re-running converges. NOTE: changing base_ref does NOT re-seed an
# existing repo — reset first (reforge-reset reset).
#
# reforge-seed
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}
CONFIG_DIR=${REFORGE_CONFIG_DIR:?set REFORGE_CONFIG_DIR to your run config dir (manifest.txt, charter.md, agenda.md)}
TOKEN_FILE=${REFORGE_ADMIN_TOKEN_FILE:-$TOKENS_DIR/${ADMIN_USER}.token}
API="$FORGE_URL/api/v1"
TOKEN=$(cat "$TOKEN_FILE")
MANIFEST="$CONFIG_DIR/manifest.txt"
[ -r "$MANIFEST" ] || { echo "seed: no readable manifest at $MANIFEST" >&2; exit 1; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Loud abort: a mid-run failure (bad pin, unreachable upstream) must not
# scroll by unnoticed — the run is incomplete until "converged" prints.
trap 'echo; echo "seed: ABORTED — fix the error above and re-run (completed repos are skipped)." >&2' ERR
RESP="$WORK/resp"
api() { # method path [json-body] -> echoes HTTP code, body in $RESP
local method=$1 path=$2 data=${3:-}
local args=(
-sS -o "$RESP" -w '%{http_code}' -X "$method"
-H "Authorization: token $TOKEN"
-H 'Content-Type: application/json'
)
if [ -n "$data" ]; then args+=(--data "$data"); fi
curl "${args[@]}" "$API$path"
}
ok() { case $1 in 200 | 201 | 204) return 0 ;; *) return 1 ;; esac }
must() { # code context
if ! ok "$1"; then
echo "seed: $2 failed (HTTP $1):" >&2
cat "$RESP" >&2
exit 1
fi
}
auth_url() { # name
echo "http://$ADMIN_USER:$TOKEN@${FORGE_URL#http://}/$ORG/$1.git"
}
ensure_repo() { # name description
if [ "$(api GET "/repos/$ORG/$1")" = 404 ]; then
must "$(api POST "/orgs/$ORG/repos" \
"{\"name\":\"$1\",\"private\":true,\"auto_init\":false,\"default_branch\":\"main\",\"description\":\"$2\"}")" \
"create repo $ORG/$1"
echo " created repo $ORG/$1"
fi
}
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" \
'{"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"
fi
}
seed_fork() { # name upstream ref
local name=$1 url=$2 ref=$3 sha
echo "-- $name <- $url @ $ref"
ensure_repo "$name" "clean base of $url (no downstream work)"
if [ -n "$(git ls-remote "$(auth_url "$name")" refs/heads/main)" ]; then
echo " main already seeded, skipping (reset to re-seed at a new base_ref)"
else
if [ "$ref" = auto ]; then
ref=$(git ls-remote --symref "$url" HEAD |
awk '/^ref:/ {sub("refs/heads/", "", $2); print $2}')
fi
echo " cloning at $ref"
if git clone --quiet --single-branch --branch "$ref" "$url" "$WORK/$name" 2>/dev/null; then
: # branch or tag — single-branch history
else
# bare commit SHA — needs a full clone, then detached checkout
git clone --quiet "$url" "$WORK/$name"
git -C "$WORK/$name" checkout --quiet "$ref"
fi
sha=$(git -C "$WORK/$name" rev-parse HEAD)
git -C "$WORK/$name" push --quiet "$(auth_url "$name")" "HEAD:refs/heads/main"
rm -rf "${WORK:?}/$name"
echo " seeded main = $sha ($ref)"
fi
protect_main "$name"
}
echo "== charter: in-forge copy of the standard + run agenda =="
ensure_repo charter "project charter — the standard changes are judged against, plus the run agenda"
if [ -z "$(git ls-remote "$(auth_url charter)" refs/heads/main)" ]; then
git init --quiet -b main "$WORK/charter"
cp "$CONFIG_DIR/charter.md" "$WORK/charter/README.md"
cp "$CONFIG_DIR/agenda.md" "$WORK/charter/AGENDA.md"
git -C "$WORK/charter" add README.md AGENDA.md
git -C "$WORK/charter" \
-c user.name="$ADMIN_USER" -c user.email="$ADMIN_USER@sandbox.invalid" \
commit --quiet -m "charter: the standard + run agenda"
git -C "$WORK/charter" push --quiet "$(auth_url charter)" main
rm -rf "${WORK:?}/charter"
echo " seeded charter (README.md + AGENDA.md <- $CONFIG_DIR)"
fi
protect_main charter
echo
echo "== stack repos (from the manifest) =="
mapfile -t ROWS < <(grep -Ev '^[[:space:]]*(#|$)' "$MANIFEST")
for row in "${ROWS[@]}"; do
IFS='|' read -r name kind upstream base_ref _target_url _target_ref <<<"$row"
case $kind in
fork)
seed_fork "$name" "$upstream" "$base_ref"
;;
original)
echo "-- $name (placeholder)"
ensure_repo "$name" "placeholder — original software, to be rebuilt"
;;
*)
echo "seed: unknown kind '$kind' for $name in $MANIFEST" >&2
exit 1
;;
esac
done
echo
echo "seed: converged. Repos: $FORGE_URL/$ORG"

165
scripts/reforge-smoke.sh Normal file
View file

@ -0,0 +1,165 @@
#!/usr/bin/env bash
# End-to-end smoke test of the sandbox forge toolchain (docs/architecture.md).
# Run after every reset, before starting a run: exercises the full issue ->
# branch -> PR -> review-gated merge flow against the working repo with the
# provisioned role accounts, asserting both the happy path AND the gates:
#
# 1. backend-dev files an issue
# 2. backend-dev pushes a feature branch
# 3. backend-dev opens a PR referencing the issue
# 4. merging WITHOUT approval is BLOCKED (branch protection)
# 5. direct push to main is REJECTED (branch protection)
# 6. reviewer leaves a comment review
# 7. security-lead approves
# 8. merge after approval succeeds
# 9. main contains the change
#
# Leaves the issue/PR in the working repo as a record; exits non-zero if
# any step fails.
#
# reforge-smoke
set -euo pipefail
FORGE_URL=${REFORGE_FORGE_URL:-http://localhost:3030}
ORG=${REFORGE_ORG:-sandbox-team}
REPO=${REFORGE_REPO_NAME:-sandbox-project}
TOKENS_DIR=${REFORGE_TOKENS_DIR:-/var/lib/forgejo-sandbox/tokens}
API="$FORGE_URL/api/v1"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
RESP="$WORK/resp"
STAMP=$(date +%s)
tok() { cat "$TOKENS_DIR/$1.token"; }
api() { # user method path [json-body] -> echoes HTTP code, body in $RESP
local user=$1 method=$2 path=$3 data=${4:-}
local args=(
-sS -o "$RESP" -w '%{http_code}' -X "$method"
-H "Authorization: token $(tok "$user")"
-H 'Content-Type: application/json'
)
if [ -n "$data" ]; then args+=(--data "$data"); fi
curl "${args[@]}" "$API$path"
}
pass=0 fail=0
PASS() {
echo " PASS $*"
pass=$((pass + 1))
}
FAIL() {
echo " FAIL $*"
fail=$((fail + 1))
}
die() {
FAIL "$*"
cat "$RESP" >&2 || true
exit 1
}
# ── wait for the API ────────────────────────────────────────────────
ready=
for _ in $(seq 1 30); do
if curl -sf "$FORGE_URL/api/healthz" >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
[ "$ready" = 1 ] || {
echo "smoke: forge at $FORGE_URL is not healthy" >&2
exit 1
}
echo "== 1. backend-dev files an issue"
code=$(api backend-dev POST "/repos/$ORG/$REPO/issues" \
"{\"title\":\"smoke $STAMP: verify PR flow\",\"body\":\"Tooling smoke test — issue/branch/PR/review/merge round-trip.\"}")
[ "$code" = 201 ] || die "issue create (HTTP $code)"
issue=$(jq .number <"$RESP")
PASS "issue #$issue created"
echo "== 2. backend-dev pushes a feature branch"
clone_url="http://backend-dev:$(tok backend-dev)@${FORGE_URL#http://}/$ORG/$REPO.git"
git clone --quiet "$clone_url" "$WORK/repo"
git -C "$WORK/repo" checkout --quiet -b "smoke/$STAMP"
echo "smoke $STAMP" >"$WORK/repo/smoke-$STAMP.txt"
git -C "$WORK/repo" add "smoke-$STAMP.txt"
git -C "$WORK/repo" -c user.name=backend-dev -c user.email=backend-dev@sandbox.invalid \
commit --quiet -m "smoke: add smoke-$STAMP.txt (#$issue)"
if git -C "$WORK/repo" push --quiet origin "smoke/$STAMP"; then
PASS "branch smoke/$STAMP pushed"
else
FAIL "branch push"
exit 1
fi
echo "== 3. backend-dev opens a PR"
code=$(api backend-dev POST "/repos/$ORG/$REPO/pulls" \
"{\"title\":\"smoke $STAMP: PR flow\",\"head\":\"smoke/$STAMP\",\"base\":\"main\",\"body\":\"Closes #$issue\"}")
[ "$code" = 201 ] || die "PR create (HTTP $code)"
pr=$(jq .number <"$RESP")
PASS "PR #$pr opened"
echo "== 4. merge WITHOUT approval must be blocked"
code=$(api backend-dev POST "/repos/$ORG/$REPO/pulls/$pr/merge" '{"Do":"merge"}')
case $code in
200) FAIL "unapproved merge WENT THROUGH — branch protection is not active" ;;
*) PASS "unapproved merge blocked (HTTP $code)" ;;
esac
echo "== 5. direct push to main must be rejected"
git -C "$WORK/repo" checkout --quiet main
echo "direct $STAMP" >"$WORK/repo/direct-$STAMP.txt"
git -C "$WORK/repo" add "direct-$STAMP.txt"
git -C "$WORK/repo" -c user.name=backend-dev -c user.email=backend-dev@sandbox.invalid \
commit --quiet -m "smoke: direct push probe"
if git -C "$WORK/repo" push --quiet origin main 2>/dev/null; then
FAIL "direct push to main was ACCEPTED — branch protection is not active"
else
PASS "direct push to main rejected"
fi
echo "== 6. reviewer leaves a comment review"
code=$(api reviewer POST "/repos/$ORG/$REPO/pulls/$pr/reviews" \
'{"event":"COMMENT","body":"smoke: quality/alignment lens present (comment review)."}')
if [ "$code" = 200 ] || [ "$code" = 201 ]; then
PASS "reviewer comment review posted"
else
FAIL "reviewer review (HTTP $code)"
fi
echo "== 7. security-lead approves"
code=$(api security-lead POST "/repos/$ORG/$REPO/pulls/$pr/reviews" \
'{"event":"APPROVED","body":"smoke: approved by the security gate."}')
if [ "$code" = 200 ] || [ "$code" = 201 ]; then
PASS "security-lead approval posted"
else
die "security-lead approval (HTTP $code)"
fi
echo "== 8. merge after approval"
code=$(api backend-dev POST "/repos/$ORG/$REPO/pulls/$pr/merge" '{"Do":"merge"}')
if [ "$code" = 200 ]; then
PASS "PR #$pr merged"
else
die "approved merge (HTTP $code)"
fi
echo "== 9. main contains the change"
git -C "$WORK/repo" fetch --quiet origin main
if git -C "$WORK/repo" cat-file -e "FETCH_HEAD:smoke-$STAMP.txt" 2>/dev/null; then
PASS "smoke-$STAMP.txt present on main"
else
FAIL "merged file missing from main"
fi
# Soft check — informational, not a gate: "Closes #N" should have
# auto-closed the issue on merge to the default branch.
api backend-dev GET "/repos/$ORG/$REPO/issues/$issue" >/dev/null
echo " INFO issue #$issue state after merge: $(jq -r .state <"$RESP")"
echo
echo "smoke: $pass passed, $fail failed."
[ "$fail" = 0 ]

View file

@ -0,0 +1,64 @@
{
"enableAllProjectMcpServers": true,
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Read",
"Glob",
"Grep",
"Edit",
"Write",
"mcp__forgejo-sandbox",
"Bash(reforge-role:*)",
"Bash(nix build:*)",
"Bash(git -C:*)",
"Bash(git status)",
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git show:*)",
"Bash(rg:*)",
"Bash(fd:*)",
"Bash(jq:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(wc:*)",
"Bash(echo:*)",
"Bash(pwd)",
"Bash(sleep:*)",
"Bash(date)",
"Bash(mkdir:*)"
],
"ask": [
"Bash(rm:*)",
"Bash(mv:*)",
"Bash(cp:*)"
],
"deny": [
"Bash(sudo:*)",
"Bash(sudo)",
"Bash(doas:*)",
"Bash(nixos-rebuild:*)",
"Bash(nh:*)",
"Bash(systemctl:*)",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(ssh:*)",
"Bash(scp:*)",
"Bash(rsync:*)",
"Bash(nc:*)",
"Bash(git push:*)",
"Bash(git remote:*)",
"Bash(reforge-reset:*)",
"WebFetch",
"WebSearch",
"mcp__forgejo-mcp"
]
}
}

View file

@ -0,0 +1,133 @@
{
"enableAllProjectMcpServers": true,
"env": {
"GIT_SSH_COMMAND": "false"
},
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Read",
"Glob",
"Grep",
"Edit",
"Write",
"NotebookEdit",
"mcp__forgejo-sandbox",
"Bash(git status)",
"Bash(git status:*)",
"Bash(git diff)",
"Bash(git diff:*)",
"Bash(git log)",
"Bash(git log:*)",
"Bash(git show)",
"Bash(git show:*)",
"Bash(git branch)",
"Bash(git branch:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git checkout:*)",
"Bash(git switch:*)",
"Bash(git restore:*)",
"Bash(git stash)",
"Bash(git stash:*)",
"Bash(git rev-parse:*)",
"Bash(git merge:*)",
"Bash(git rebase:*)",
"Bash(git clone http://localhost:*)",
"Bash(git fetch)",
"Bash(git fetch origin:*)",
"Bash(git pull)",
"Bash(git pull origin:*)",
"Bash(git push)",
"Bash(git push origin:*)",
"Bash(git push --set-upstream origin:*)",
"Bash(git push -u origin:*)",
"Bash(git config user.name:*)",
"Bash(git config user.email:*)",
"Bash(nix build:*)",
"Bash(nix develop:*)",
"Bash(nix flake check:*)",
"Bash(nix flake show:*)",
"Bash(python3:*)",
"Bash(pytest:*)",
"Bash(uv run:*)",
"Bash(uv sync:*)",
"Bash(poetry run:*)",
"Bash(poetry install:*)",
"Bash(npm run:*)",
"Bash(npm test:*)",
"Bash(npm ci)",
"Bash(npm install)",
"Bash(pnpm run:*)",
"Bash(pnpm test:*)",
"Bash(pnpm install)",
"Bash(node:*)",
"Bash(make:*)",
"Bash(rg:*)",
"Bash(fd:*)",
"Bash(jq:*)",
"Bash(diff:*)",
"Bash(sort:*)",
"Bash(uniq:*)",
"Bash(find:*)",
"Bash(xargs:*)",
"Bash(mkdir:*)",
"Bash(touch:*)",
"Bash(ls:*)",
"Bash(pwd)",
"Bash(which:*)",
"Bash(type:*)",
"Bash(env)",
"Bash(echo:*)",
"Bash(file:*)",
"Bash(stat:*)",
"Bash(wc:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(cat:*)"
],
"ask": [
"Bash(rm:*)",
"Bash(rmdir:*)",
"Bash(mv:*)",
"Bash(cp:*)",
"Bash(chmod:*)",
"Bash(chown:*)"
],
"deny": [
"Bash(sudo:*)",
"Bash(sudo)",
"Bash(doas:*)",
"Bash(su:*)",
"Bash(nixos-rebuild:*)",
"Bash(nh:*)",
"Bash(nix run:*)",
"Bash(git remote:*)",
"Bash(git push ssh:*)",
"Bash(git push git@:*)",
"Bash(git push https:*)",
"Bash(git clone ssh:*)",
"Bash(git clone git@:*)",
"Bash(git clone https:*)",
"Bash(git fetch https:*)",
"Bash(git fetch ssh:*)",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(ssh:*)",
"Bash(scp:*)",
"Bash(rsync:*)",
"Bash(nc:*)",
"Bash(systemctl:*)",
"Bash(docker:*)",
"Bash(podman:*)",
"WebFetch",
"WebSearch",
"mcp__forgejo-mcp"
]
}
}