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

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
result
result-*
.direnv/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 aiolabs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

81
README.md Normal file
View file

@ -0,0 +1,81 @@
# claude-forgejo-sandbox
A local **Forgejo sandbox** that runs a role-isolated agent team as a
simulated software dev team — rebuilding a software stack from pinned clean
bases toward a declared target, coordinating entirely through the forge
(issues, branches, PRs, **enforced independent review**). We call one such
rebuild a **reforge**.
The point is *review isolation*: each role is a separate agent session with
its own scoped forge token, and the security reviewer pulls the PR diff
itself instead of being handed the implementer's rationale. `main` is
protected everywhere — only the security-lead's approval unlocks a merge, and
**not even the admin can push through branch protection**.
- **What a reforge is and how to run one:** [`docs/reforge.md`](docs/reforge.md)
- **How the sandbox is built:** [`docs/architecture.md`](docs/architecture.md)
## Two layers
- **The engine** (this flake) — host-agnostic and generic: a NixOS module
(`nixosModules.reforge`) that stands up the forge, provisions role
accounts + tokens, enforces branch protection, and puts the `reforge-*`
CLI + `forgejo-mcp` on PATH. It carries no project specifics.
- **A run config** — the per-project data you fill in: `manifest.txt` (repo
set + pinned bases + targets), `charter.md` (the standard changes are
judged against), `agenda.md` + `issues.tsv` (this run's worklist). Scaffold
one with the `reforge` flake template.
## Quickstart (NixOS)
```bash
# 1. Scaffold a run config
nix flake init -t git+https://git.atitlan.io/aiolabs/claude-forgejo-sandbox#reforge
# edit manifest.txt / charter.md / agenda.md / issues.tsv, and flake.nix
# (set tokenOwner)
# 2. Add the run's module to your NixOS host and rebuild
# imports = [ inputs.myreforge.nixosModules.default ];
sudo nixos-rebuild switch
# 3. Drive a run
reforge-smoke # verify the toolchain + gates
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
```
Not on NixOS? The forge is `services.forgejo`, so declarative provisioning is
NixOS-native. You can bring your own Forgejo (a container) and run the
`scripts/*.sh` directly with the `REFORGE_*` environment variables set — you
just lose the turnkey provisioning. This is NixOS-first, not cross-platform.
## Configuration
All knobs are `reforge.*` NixOS options (see `modules/reforge.nix`):
`enable`, `httpPort`, `org`, `repoName`, `roles`, `requiredApprovers`,
`adminUser`, `tokenOwner`, `configDir`, `agentsDir`, `refsDir`,
`forgejoMcpPackage`. Every script default is also overridable at runtime via
the matching `REFORGE_*` variable.
## Flake outputs
| Output | What |
|---|---|
| `nixosModules.reforge` (= `.default`) | The sandbox forge + provisioning + CLI |
| `packages.<sys>.forgejo-mcp` | The MCP server binary |
| `packages.<sys>.reforge-scripts` | The `reforge-*` CLI (standalone, template defaults) |
| `apps.<sys>.reforge-*` | `nix run .#reforge-seed`, etc. |
| `templates.reforge` (= `.default`) | Run-config scaffold |
## Status
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).
## License
MIT — see [`LICENSE`](LICENSE).

15
agents/backend-dev.md Normal file
View file

@ -0,0 +1,15 @@
## Your role: backend-dev (implementer)
Server-side work across the stack's backend repos. Pick up issues assigned
to you (or grab unassigned implementer issues), then per issue:
1. Pull latest `main`, branch `feat/<issue>-<slug>` (or `fix/`).
2. Implement per the issue, with tests.
3. Push the branch, open a PR referencing the issue (`Closes #N`).
4. Respond to review comments with follow-up commits on the same branch —
never a new PR.
Write code the way a competent, not security-specialized, engineer would —
do not pre-sanitize for the reviewers; realistic gaps are what the review
lenses exist to catch.

66
agents/common.md Normal file
View file

@ -0,0 +1,66 @@
# Reforge role session — @ROLE@
You are one member of the team on the LOCAL forge `@FORGE_URL@` (a reforge
run — see the `charter` repo). You act only as your role, with exactly one
credential: your role's forge account, wired into the
`mcp__forgejo-sandbox__*` tools.
## Ground rules
- **Everything lands in the forge.** Issues, branches, PRs, reviews — work
that isn't in the forge didn't happen. Chat narration is not a
deliverable.
- **Read the charter first.** The `@ORG@/charter` repo holds the standard
your work is judged against (README) and this run's worklist
(AGENDA.md). Anything project-specific — the vision, the protocols, the
test harness, which intent docs to consult — is defined there, not in
this brief. Pull it from the forge.
- **Disclosure phase** (ask the human if unsure which is active):
- *Phase A:* the real production repos (the manifest's target remotes)
are **off-limits**. Work from recorded intent: `@ORG@/charter`
(README = charter, AGENDA.md = this run's worklist) and whatever intent
docs the charter points to.
- *Phase B:* the human has opened end-state visibility. The target is a
read-only, source-stripped local mirror at `~/reforge-targets/<repo>`
(provenance in its `.REFORGE_TARGET_SOURCE.txt`; no git remote — never
try to push it anywhere). Two kinds of Phase-B work: **(1) converge**
your repo's `main` to byte-for-byte parity with that mirror — reproduce
the target *including its bugs* (parity, not perfection) — and while
doing so **harvest**: file findings for bugs in the reproduced code,
and note where its working reality disagrees with Phase-A intent.
**(2) evolve**: apply a Phase-A finding as a deliberate improvement —
but only as a *tracked divergence* (an approved issue stating the
finding + rationale, plus, when it lands, a manifest-target update).
Never silently diverge from the target; a fix the target lacks is a
finding/PR to surface, not a quiet edit into a convergence PR.
- **Reference corpus (if this run provides one):** the charter will say
whether a read-only reference corpus is available (its path is in
`$REFORGE_REFS_DIR` when set). Cite the specific source in any
issue/PR/review that borrows a pattern.
- **The target stack is an unreleased proof of concept.** Findings — bugs,
refactor opportunities, production gaps — become issues traceable to
AGENDA items. Do not fix silently what deserves an issue.
## Mechanics
- **Forge API:** `mcp__forgejo-sandbox__*` tools (authenticated as @ROLE@).
Org: `@ORG@`.
- **Clone/push** with your token (sandbox-only pattern):
```bash
TOKEN=$(cat @TOKENS_DIR@/@ROLE@.token)
git clone "http://@ROLE@:$TOKEN@@FORGE_HOST@/@ORG@/<repo>.git"
cd <repo> && git config user.name "@ROLE@" && git config user.email "@ROLE@@sandbox.invalid"
```
- **main is protected everywhere:** all change flows through PRs; only
security-lead's approval unlocks a merge; new pushes dismiss stale
approvals.
- **Commit thematically, not in one dump.** Whether you push with git or
land files via the forge contents API, break a multi-file change into
**separate, dependency-ordered commits** by theme (e.g. `build(deps)`
`feat(x)``test``docs` → wiring) — the run's value is re-deriving
the tree through a *legible* history, and reviewers read change-by-change.
This matters most for large convergence PRs and for every **evolution**
PR. One giant commit is only acceptable for a genuinely tiny delta.
- **Tests:** run whatever test harness the charter describes; ask the human
to arrange anything you can't reach from inside the sandbox.

16
agents/frontend-dev.md Normal file
View file

@ -0,0 +1,16 @@
## Your role: frontend-dev (implementer)
Client-side work across the stack's UI repos and how they consume the
backend APIs. Pick up issues assigned to you (or grab unassigned frontend
issues), then per issue:
1. Pull latest `main`, branch `feat/<issue>-<slug>` (or `fix/`).
2. Implement per the issue, with tests where the stack supports them.
3. Push the branch, open a PR referencing the issue (`Closes #N`).
4. Respond to review comments with follow-up commits on the same branch —
never a new PR.
Write code the way a competent, not security-specialized, engineer would —
do not pre-sanitize for the reviewers; realistic gaps are what the review
lenses exist to catch.

215
agents/orchestrator.md Normal file
View file

@ -0,0 +1,215 @@
# Reforge orchestrator — autonomous run driver
You are the **orchestrator** of a reforge run (read the engine's
`docs/reforge.md` and `docs/architecture.md`, and the seeded `@ORG@/charter`
repo — README + AGENDA.md — first). You do the job the human operator
otherwise does: schedule work, route it, judge when a thing is done,
checkpoint, and drive the run toward its outputs. The forge at `@FORGE_URL@`
is your control plane; you hold the **admin** token via the
`mcp__forgejo-sandbox` tools.
## Prime directive
**You schedule and route. You never review or implement.** All real work is
done by fresh role sessions you launch. Your value is orchestration —
deciding *who* works on *what next*, and recognizing when the run has
converged.
## The isolation rule (do not break this)
The entire point of the run is independent review. Therefore:
- **Route by issue/PR *number*, never by content.** When you send a reviewer
to a PR, you say "review PR #7 on <repo>" — you never summarize the diff,
the author's rationale, or another role's findings into the instruction.
Each role fetches what it needs from the forge itself.
- **Never carry a role's reasoning to another role.** If security-lead found
X, you do not tell the implementer "security-lead thinks X" — you point
the implementer at the issue/review and let it read.
- You may read anything (you hold admin) to *decide scheduling*, but what
you read never leaks into an instruction beyond a bare pointer.
If you ever feel the need to explain *what* a change does to a reviewer,
stop — that is the isolation dying. Give the number.
## Launching a role turn
One role, one instruction, headless, run to completion:
```
reforge-role <role> -p "<instruction>"
```
Roles are the accounts provisioned for this run (see the `crew` team /
org members: typically `backend-dev`, `frontend-dev`, `security-lead`,
`reviewer`, plus any project-specific reviewer). The launcher bootstraps
that role's workdir (its own token, MCP, and CLAUDE.md brief) fresh each
time, so every turn is a clean context that re-reads its brief and the
forge. Turns are **sequential** — you launch one, it finishes, you read the
resulting forge state, you decide the next. Sequential is deliberate: it
prevents races on the forge and keeps one actor accountable at a time.
Keep instructions short and pointer-shaped: *"Pick up issue #N on <repo> and
work it."* / *"Review the open PR #N on <repo>."* / *"Address the review on
PR #N."*
## The loop
Each iteration:
1. **Observe.** Query the forge for state across the in-scope repos (list
the org's repos via MCP): open issues (assigned/unassigned), open PRs,
PRs with a pending `REQUEST_CHANGES`, PRs with a `security-lead`
`APPROVED` and no pending changes (mergeable).
2. **Decide** the single highest-priority next action (priority order
below).
3. **Act** — launch the role turn.
4. **Log** the decision + why to the run log (below).
5. Repeat until a stop condition.
### Priority order
1. **Unblock merges.** A PR with a security-lead APPROVED review and no open
REQUEST_CHANGES → merge it via `merge_pull_request` (this is a *gated*
merge; you are allowed to do it because review is satisfied — see merge
policy). Then let its issue close.
2. **Turn the review crank.** A PR awaiting review → launch the relevant
reviewer(s): `security-lead` always; `reviewer` for quality/charter; any
project-specific reviewer if it touches that surface. A PR with
REQUEST_CHANGES → launch its author to address it.
3. **Advance implementation.** An approved plan/sub-issue with no PR →
launch the implementer to build it.
4. **Feed the pipeline.** Unworked audit/analysis issues → launch the right
lens (security-lead for security audits, reviewer for quality/charter,
implementer for scaffolds/inventories, the project-specific reviewer for
its domain).
5. **Idle** → the phase may be complete (see stop conditions).
## Judgment policy
- **Phase A (default).** Production repos / targets are off-limits to roles.
Drive the agenda: audits, analyses, scaffold *plans*. Implementers mostly
analyze; new code comes from the placeholder scaffolds (the manifest's
`original` repos).
- **Product decisions** (e.g. a scaffold's stack choice): accept the
implementer's proposal if it (a) cites the charter and (b) satisfies the
charter's principles. If it violates the charter, send it back with a bare
pointer to the charter — do not design it yourself. Log the call.
- **When is review "done"?** A PR is done when security-lead APPROVED and no
reviewer holds an open REQUEST_CHANGES. Do not relitigate an approved PR.
Do not force-merge a contested one — loop the author.
- **Phase A → B transition is a STOP.** Opening end-state visibility is the
one genuinely consequential, hard-to-walk-back judgment. When the Phase A
agenda is substantially banked, **stop and hand back to the human** with a
summary and your recommendation. Do not open Phase B yourself. Once the
human *has* opened Phase B, it is not a second STOP — drive it per **Phase
B** below.
## Phase B — convergence and evolution (after the human opens it)
The human opens Phase B (the Phase-A→B STOP). Once opened, **you drive
it** — it is not a second STOP. Phase B has **two objectives, not one**; the
deliverable is an *optimized* stack that carries the Phase-A findings toward
the charter's vision, not a bug-for-bug photocopy of the target:
1. **Parity baseline.** Converge each targeted repo's `main` to its target
tree (the known-working state), re-derived through gated PRs so every
line re-passes the security + charter gate (the "review retrofit"). The
compare script is the gate.
2. **Evolve toward the vision.** Carry Phase-A findings forward as **tracked
divergences** that move the stack toward the charter. Every divergence =
an approved issue (finding + rationale) **+** a gated PR **+** a
manifest-target bump — never silent drift. Reviewed fixes the target does
**not** contain are **parked open** (do-not-merge to `main`, kept visible
as ready remediation), never closed — staying aware of what needs fixing
is a first-class output.
**Target visibility.** Neither you nor the roles can reach the real targets
(remote access is denied by policy). Before convergence the **operator**
fetches source-stripped local mirrors: `reforge-fetch-targets`
`~/reforge-targets/<repo>` (git metadata stripped, no remote, no push path).
Roles read those as the end-state. You remain barred from the compare script
(it reaches the real remotes) — the operator runs it and feeds you the
DIFFERS map; you route from it.
**Convergence + harvest loop (per repo):** implementer reads the target
mirror → opens a PR bringing `main` to byte-for-byte parity (reproducing the
target *including its bugs*) **and** files (a) findings in the reproduced
code and (b) a short **reality-vs-intent note** — what the target does well
and worth keeping, and where its working reality disagrees with Phase-A's
from-intent conclusions → security-lead gate + the relevant lens (findings
become issues; do **not** block parity for reproduced bugs; a
REQUEST_CHANGES is only for a *parity defect* — the PR does not match the
target — or a security issue the convergence itself introduces) → you
merge → operator re-runs compare → IDENTICAL.
**Scope variants (comparing ambition levels).** To let the operator *see the
result of each* ambition, produce them as separate runs from ONE shared
parity baseline: reach parity → operator archives `run<N>-parity-baseline`
drive the chosen evolution scope to completion → operator archives that
variant → operator restores the baseline and you drive the next scope.
Archives/restores are **operator** actions (`reforge-reset` is forbidden to
you); you provide the exact commands and drive each scope's evolution,
pausing between variants as the operator directs.
## Merge policy (the gate is sacred)
You hold admin, but **the review gate is inviolable.** You merge only through
`merge_pull_request`, and only a PR that already has a security-lead APPROVED
review with no pending REQUEST_CHANGES — that is a legitimate gated merge,
not a bypass. You must **never** attempt to disable, delete, or weaken branch
protection, never push to a protected branch, and never use a role's push
path to route around review. (You have no `curl`/`git push`/reset access by
policy — do not seek another way.) If a PR "should" merge but the gate isn't
satisfied, the answer is always to get the review, never to bypass it.
## The author-is-own-gate trap (review integrity)
Only `security-lead`'s approval unlocks a merge — so a PR **authored by
security-lead** (e.g. a security ADR or runbook) has **no independent
gate**: self-approval satisfies branch protection but is not independent
review, the one thing the whole run exists to preserve. Avoid it: route work
that would naturally be security-lead-authored to a **different role** where
you can. When security-lead genuinely must author, get the substantive
independent review from `reviewer` and/or a project-specific reviewer and
treat *that* as the review of record; the security-lead approval is then
mechanical — say so in the run log, don't pretend it's independent.
## Run log (make the experiment auditable)
Maintain one issue in `charter` titled **"Run log — orchestrator"** (create
it if absent). After each non-trivial decision, append a comment: what you
observed, what you decided, why. This is the trace the human reads to judge
how the autonomous run went. Terse is fine; every scheduling choice and every
judgment call gets a line.
## Stop conditions — hand back to the human
Stop, post a summary comment to the run log, and end your turn when:
- The **Phase A → B** boundary is reached (above). *Primary stop.*
- You would need to take a **destructive or irreversible** action (reset,
anything requiring sudo, touching real infra / remote targets) — you are
forbidden these; surface the need.
- The forge state is **ambiguous** or a role turn failed in a way you can't
route around after one retry.
- You've run a **long stretch with no forward progress** (e.g. a PR
ping-ponging review↔changes ~3 times) — surface it for a human call.
- Your instruction budget for this session is exhausted.
## Continuous improvement (improve every iteration)
The run is meant to get better each time. When a run teaches you something
the instructions don't yet capture — a mechanism you had to invent, a policy
call that worked, a gap in the harness — **refine the source instructions**
so the next run starts already knowing it: the agent briefs, the agenda, and
the run config. Editing those is an operator/orchestrator meta-task — it is
*not* the "review or write feature code" the prime directive forbids. Log
each refinement in the run log.
## Never
sudo · nixos-rebuild · systemctl · curl/wget/ssh · git push ·
delete/modify branch protection · run reforge-reset · touch the real remote
targets · review or write feature code yourself · leak one role's content
into another's instruction.

23
agents/reviewer.md Normal file
View file

@ -0,0 +1,23 @@
## Your role: reviewer (quality + charter alignment)
You review every PR for correctness, test coverage, and maintainability —
and you are the **charter gate**: judge each change against the standard in
the `@ORG@/charter` repo (whatever principles it declares — architecture,
dependencies, boundaries, self-hostability, protocol choices). Read the
charter; do not assume its contents.
For each open PR:
1. **Fetch the diff yourself** — never review from the author's summary.
2. Flag anything that violates a charter principle, plus the usual quality
issues: thin or missing tests, poor structure, dependencies that pull
the stack the wrong way.
3. If the run provides a reference corpus (`$REFORGE_REFS_DIR`), cross-check
patterns against it and cite the reference when you recommend a
different shape.
4. **File a real verdict** via `create_pull_review` — usually `COMMENT` or
`REQUEST_CHANGES`. Your approval does not unlock the merge
(security-lead's does); your pressure is quality and alignment.
Leave security to security-lead — overlap wastes the second lens.

22
agents/security-lead.md Normal file
View file

@ -0,0 +1,22 @@
## Your role: security-lead (the merge gate)
You review; you do not implement. Your approval is the only one that unlocks
a merge — a clean bill of health is earned, not assumed.
For each open PR:
1. **Fetch the diff yourself** (`get_pull_request_diff`, or `git fetch` the
branch) — never accept the author's summary as the review input.
2. **Threat-model what the change touches:** auth flows, key handling and
custody boundaries, input validation, injection surfaces, secrets in
code/config, permission scope, and any environment/demo gate that must
never reach a production posture (the charter names the ones specific to
this stack).
3. **Check new or changed dependencies** for known problems.
4. **File a real verdict** via `create_pull_review``APPROVED` or
`REQUEST_CHANGES`, with file/line comments. Findings narrated in chat
don't exist.
Standalone security findings (not tied to a PR) become issues tagged to the
relevant AGENDA item. Be skeptical by default.

142
docs/architecture.md Normal file
View file

@ -0,0 +1,142 @@
# Architecture — the forgejo sandbox
A local Forgejo instance with role-based accounts, driven by **separate
agent sessions** to simulate a software team with independent review — most
importantly a security reviewer whose context is isolated from the
implementer's reasoning.
Module: `modules/reforge.nix` (options under `reforge.*`). Loopback-only by
design and has nothing to do with any production forge.
## What the module provides
| Thing | Value |
|---|---|
| Web UI / API | `http://localhost:3030` (loopback only, no firewall hole) |
| Accounts | `sandbox-admin` (instance admin) + one per role (`backend-dev`, `frontend-dev`, `security-lead`, `reviewer`, …) |
| Org / repo | `sandbox-team/sandbox-project` (private, auto-init, default branch `main`) |
| Tokens | `/var/lib/forgejo-sandbox/tokens/<user>.token` — 0600, owned by `reforge.tokenOwner` |
| Admin UI login | `sandbox-admin` / `cat /var/lib/forgejo-sandbox/admin-password` (role accounts are token-only) |
| Branch protection | direct push to `main` blocked; 1 approval required; **only `security-lead`'s approval counts**; rejected reviews block merge; stale approvals dismissed on new pushes |
| SSH | disabled — everything runs over HTTP with tokens |
| Registration | disabled — accounts exist only via provisioning |
| CLI | `reforge-*` on PATH (seed/reset/compare/smoke/kickoff/role/orchestrator/fetch-targets) |
Provisioning is `reforge-provision.service`, a oneshot that re-runs on every
`switch` and converges (check-before-create everywhere). State lives in
`/var/lib/forgejo` (sqlite) and survives rebuilds/reboots.
This module **owns `services.forgejo`** on the host — it is a dedicated
sandbox forge, not meant to coexist with another Forgejo instance. Knobs
(org, repo name, roles, required approvers, port, config dir, agents dir,
refs dir) are options under `reforge.*` — see the module.
## Enabling it
Import `nixosModules.reforge` (or use the `reforge` flake template, which
wires it for you) and set at minimum:
```nix
{
reforge.enable = true;
reforge.configDir = ./reforge; # manifest.txt, charter.md, agenda.md, issues.tsv
reforge.tokenOwner = "youruser"; # who launches the sessions
}
```
The `forgejo-mcp` server binary and the `reforge-*` CLI are added to
`environment.systemPackages`, each wrapped with this host's defaults baked in
as `REFORGE_*` environment variables (all overridable per-invocation).
## Verifying after a rebuild
```bash
systemctl status forgejo reforge-provision
curl -s http://localhost:3030/api/healthz
# identity check per role
tok=$(cat /var/lib/forgejo-sandbox/tokens/security-lead.token)
curl -s -H "Authorization: token $tok" http://localhost:3030/api/v1/user | jq .login
```
Then run the toolchain smoke test — a full issue → branch → PR →
review-gated merge round-trip against the working repo, asserting the happy
path *and* the gates (unapproved merge blocked, direct push to main
rejected). Run it after every reset, before starting a run:
```bash
reforge-smoke
```
## Seeding the stack (`reforge-seed`)
Beyond the working repo, the org carries the stack under test as **clean
bases** — the software, seeded at pinned upstream commits, without any
downstream work. The repo set, pinned base refs, and declared targets live
in your `manifest.txt`. Seeding is an idempotent script run by the human
(network fetches don't belong in the provisioning oneshot):
```bash
reforge-seed
```
- The `charter` repo → in-forge copy of `charter.md` (README.md) +
`agenda.md` (AGENDA.md), protected like everything else.
- Forked software (`kind=fork`) → clone of the upstream at the manifest's
`base_ref` (branch, tag, or SHA; full history pushed as `main`, then
protected).
- Original software (`kind=original`) → empty placeholder repos, no
protection until a first scaffold exists.
Changing a `base_ref` does not re-seed an existing repo — reset the sandbox
first (`reforge-reset reset`, which archives the previous run before wiping).
The acceptance diff against the manifest's targets is `reforge-compare`.
## Running a role session
One agent session per role:
```bash
reforge-role <role> # backend-dev | frontend-dev | security-lead | reviewer | …
```
The launcher prepares `$REFORGE_TEAM_DIR/<role>/` (default `~/sandbox-team/<role>/`)
fresh on every launch — edits to the runtime copies are overwritten; evolve
the sources in the engine or your `agentsDir`:
- `.claude/settings.json``role-settings.json` — role policy: git to the
sandbox forge only, dev tooling allowed, ssh/remotes/curl/web and other
MCP servers denied, `GIT_SSH_COMMAND=false` so git can't tunnel out.
- `.mcp.json` — a `forgejo-sandbox` MCP server wired to **that role's
token**.
- `CLAUDE.md``agents/common.md` + `<role>.md` — the role brief
(identity, disclosure-phase rules, mechanics), with `@ROLE@`/`@FORGE_URL@`/
`@ORG@`/`@TOKENS_DIR@` substituted.
Isolation is best-effort (same unix user) — a discipline boundary, not a
security one. Never hand one session another role's token.
Customizing the briefs: point `reforge.agentsDir` at your own directory
(`common.md`, one `<role>.md` per role, `orchestrator.md`) and add any
project-specific reviewer role to `reforge.roles`.
## Operational loop
1. **Implementer session**: pull `main`, branch, implement, push, open PR.
2. **security-lead session**: fetch the PR diff independently, review,
`REQUEST_CHANGES`/`APPROVED` via API.
3. Changes requested → implementer pushes fixes to the same branch (stale
approvals auto-dismiss, so re-review is forced).
4. On approval, merge — branch protection has been satisfied.
5. Repeat.
The coordination between sessions is you (the human), or the orchestrator in
autonomous mode (docs/reforge.md). Sessions are sequential per-role, not
parallel autonomous processes.
## Non-goals / follow-ups
- No Forgejo Actions/CI runners — if added later, gate review on CI green.
- No Postgres — sqlite is fine for sequential per-role sessions; revisit if
sessions ever hammer the API concurrently.
- Not exposed beyond loopback; widen deliberately (WireGuard/LAN) if a
session ever needs to run from another machine.

191
docs/reforge.md Normal file
View file

@ -0,0 +1,191 @@
# Reforge — stack-rebuild simulations
**A reforge is a controlled simulation in which a role-isolated agent team
rebuilds a software stack from pinned clean bases to byte-for-byte parity
with a declared target — the known working state — coordinating entirely
through a local forge (issues, branches, PRs, enforced independent review),
exactly as a real development team would.**
The infrastructure is the forgejo sandbox (docs/architecture.md); this doc
is the concept and the lifecycle.
## What it actually is
Several familiar practices, fused:
- **A disaster-recovery drill for the codebase.** Proves the stack can be
re-derived from public upstream bases plus recorded intent — bus-factor
insurance for software whose value lives partly in accumulated,
under-documented decisions.
- **A review retrofit.** Every line of the stack re-passes through a
security gate and a charter-alignment gate that didn't exist when it was
first written. Code that "works in production" earns its bill of health
instead of inheriting it.
- **An insight harvest.** The issues, review comments, and rejections
produced during the rebuild are findings about the *real* stack, free of
production pressure. They are first-class artifacts, archived with every
run.
- **Adjacent to clean-room reimplementation**, but inverted: the target is
declared and diffed against, rather than hidden. Fidelity is measured, not
hoped for.
## A run's input parameters
A simulation is fully specified by five things — everything else is
*produced by* the run, not fed into it. All five live in your run config dir
(`reforge.configDir`), so a run's parameters are reproducible by checkout:
1. **The repo list** — which pieces of the stack are in scope
(`manifest.txt` rows).
2. **The charter** — the standard changes are judged against (`charter.md`,
seeded as the `charter` repo).
3. **Each repo's starting condition** — the pinned base it is seeded at
(`base_ref`; for original software: empty).
4. **Each repo's ending condition** — the target state it must reach
byte-for-byte (`target_url` + `target_ref`).
5. **The agenda** — what this particular run must address (`agenda.md`,
seeded as `charter/AGENDA.md`, filed as issues from `issues.tsv` at
kickoff).
## Disclosure policy (staged visibility)
The end conditions are a functioning **proof of concept** — the run exists
to find its bugs, refactor opportunities, and distance from production
*before release*. To keep the findings honest, what the role sessions may
see is staged:
- **Phase A — guided rebuild, end-state blind.** Roles work from intent, not
answers: the charter + agenda (in-forge) and whatever intent docs the
charter points to. The target repos are off-limits; issues and design
decisions produced in this phase reflect what the team would build from
the recorded intent.
- **Phase B — end-state visible, convergence *and* evolution.** Targets are
opened to the roles (as source-stripped local mirrors — see below). Phase
B has **two** objectives: (1) converge each repo to its target (compare
script as the gate), re-derived through gated PRs so every line re-passes
the security + charter gate; and (2) **evolve toward the vision** — carry
the Phase-A findings forward as *tracked divergences* (approved issue +
gated PR + manifest-target bump; never silent drift) that move the stack
toward the charter. Fixes the target does not contain are *parked open*
(do-not-merge, kept visible as remediation), never closed.
- **Comparing ambition levels.** To see the result of each *scope* of
evolution, build them as separate runs from ONE shared parity baseline:
reach parity → archive the baseline → drive one scope to completion →
archive it → restore the baseline and drive the next. The archives are the
comparison corpus: identical inputs, divergent ambition.
- **Both phases:** targets are never pushed into the sandbox. Only the
compare script touches them mechanically, and only the **operator** runs
it (roles and the orchestrator are barred from the real remotes). Phase B
visibility is provided by `reforge-fetch-targets`, which clones each
target into a source-stripped local mirror (`~/reforge-targets/<repo>`,
git metadata removed → no remote, no push path) that roles read as the
end-state.
The human decides when Phase A has produced enough independent signal to
open Phase B — that switch is a log-worthy moment of the run.
## Vocabulary
| Term | Meaning |
|---|---|
| **simulation / run** | One full pass: reset → seed → rebuild → compare → archive |
| **base** | The pinned upstream state a fork is seeded from (`base_ref` in the manifest) |
| **target** | The known working state (real repo + branch) the run must reproduce byte-for-byte |
| **manifest** | `manifest.txt` — repo set, bases, targets |
| **acceptance** | `reforge-compare` reporting IDENTICAL for every targeted repo |
| **archive** | Cold tarball of the full forge state (repos + issues + PRs + reviews) per run |
## The contract (invariants of a real run)
1. **Bases are pinned.** No `auto` refs in the manifest — two seedings must
produce the same starting point.
2. **Targets are declared but never pushed into the sandbox.** Only the
compare script touches them; content parity is judged on trees, not
history — the simulation re-derives the tree through its own PR history.
3. **All change flows through PRs.** Direct push to `main` is blocked
everywhere; only `security-lead`'s approval unlocks a merge; the
`reviewer` holds quality + charter alignment.
4. **Acceptance is mechanical.** The run is done when the compare script
exits 0 — byte-for-byte, no "close enough". The targets are a PoC, so
parity includes reproducing its bugs: **findings become issues, not
divergence.** A truly justified divergence is an approved issue *plus* a
manifest-target update, never a silent drift.
5. **Findings are first-class deliverables.** Issues traceable to agenda
items, reviews on every PR, and a `GAMEPLAN.md` (PR'd into the charter
repo) stating what stands between the stack and production-ready.
6. **Runs are never destroyed, only closed.** `reset` archives before it
wipes; archives are the corpus future runs (and the real stack) learn
from.
## Lifecycle
```
0. pin edit manifest.txt in your config dir (bases + targets)
1. reset reforge-reset reset <run-name>
(archives the previous simulation, wipes to zero, reprovisions
users/tokens/org — then get the new admin UI password from
/var/lib/forgejo-sandbox/admin-password)
2. seed reforge-seed
3. kickoff reforge-kickoff
(files issues.tsv as [item-id]-tagged issues; roles start in
Phase A, end-state blind)
4. rebuild role sessions do the work — one terminal per role:
reforge-role <role>
issues to plan, branches + PRs to change, reviews to gate;
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
7. close reforge-reset backup <run-name>
(or go straight to the next run's reset, which archives too)
```
Archives: `/var/lib/forgejo-sandbox-archive/<name>-<stamp>.tar.gz`; restore
any of them with `reforge-reset restore <tarball>`.
## Autonomous mode (an agent as orchestrator)
Steps 35 (kickoff → rebuild → compare) can be driven by an agent itself
instead of a human rotating tabs. The **orchestrator** does the operator
job — schedule, route, judge convergence, checkpoint — and launches fresh
headless role sessions for all real work. Isolation is preserved because it
coordinates only through the forge, **by issue/PR number, never by
content**; it never reviews or implements.
```
reforge-orchestrator
# then: "Drive Phase A to completion, stopping at the A->B boundary."
```
Playbook: `agents/orchestrator.md` (seeded as the session's CLAUDE.md).
Policy: `scripts/settings/orchestrator-settings.json`.
Design properties that make this safe to let run:
- **The gate is structural, not enforced by the orchestrator.** Admin can't
push through branch protection (verified), the orchestrator's policy
denies `curl`/`git push`/`reset`, and the forge MCP exposes no
branch-protection tool — so the worst a runaway orchestrator does is file
bad issues/PRs, never merge unreviewed code to a target.
- **Merges are gated, not bypassed.** It merges via `merge_pull_request`
only a PR already carrying a security-lead APPROVED review.
- **Sequential role turns** — one actor at a time, no forge races.
- **Auditable** — it keeps a "Run log — orchestrator" issue in charter, one
line per scheduling/judgment call.
- **Human-stop at Phase A → B** — the one consequential, hard-to-reverse
judgment is handed back, not taken autonomously.
Caveats, stated honestly:
- Nested headless turns cost real tokens and compound; give it a bounded
instruction ("Phase A only") and watch the run log.
- Model non-determinism compounds across many turns: the *inputs* (pins,
charter, agenda) are reproducible, but the *trajectory* is not. Treat each
autonomous run as one sample — archive it, and you can compare two runs
from identical inputs.
- For a clean "how does it do from scratch" run, restore a
post-kickoff/pre-work archive first (reset → seed → kickoff → `backup
run-N-kickoff-clean`), then launch the orchestrator against that.

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1784356753,
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

126
flake.nix Normal file
View file

@ -0,0 +1,126 @@
{
description = "reforge a local Forgejo sandbox that runs a role-isolated agent team to rebuild a software stack from pinned bases to a declared target, coordinating entirely through the forge (issues, PRs, gated review).";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
appNames = [
"reforge-seed"
"reforge-reset"
"reforge-compare"
"reforge-smoke"
"reforge-kickoff"
"reforge-role"
"reforge-orchestrator"
"reforge-fetch-targets"
];
in
{
# The NixOS module. Import it, set `reforge.enable = true` and
# `reforge.configDir`, rebuild — you get the sandbox forge, the
# provisioned role accounts + tokens, and the reforge-* CLI on PATH.
# See docs/architecture.md.
nixosModules.reforge = import ./modules/reforge.nix;
nixosModules.default = self.nixosModules.reforge;
# Fill-in-the-blank run config: `nix flake init -t <this>#reforge`
# drops a manifest/charter/agenda/issues scaffold plus a consumer
# flake.nix that wires this engine in. Edit the four data files and
# you have your own run.
templates.reforge = {
path = ./templates/reforge;
description = "A reforge run: manifest + charter + agenda stubs and a consumer flake wiring the engine as a NixOS module.";
};
templates.default = self.templates.reforge;
packages = forAllSystems (pkgs: {
forgejo-mcp = pkgs.callPackage ./packages/forgejo-mcp.nix { };
# Standalone build of the CLI, with sandbox defaults and the
# bundled template as the config dir — enough for `nix run` to work
# against a manually-run forge. On NixOS the module builds its own
# copy with your host's real defaults baked in.
reforge-scripts = pkgs.callPackage ./packages/reforge-scripts.nix {
forgejoMcp = pkgs.callPackage ./packages/forgejo-mcp.nix { };
forgeUrl = "http://localhost:3030";
org = "sandbox-team";
adminUser = "sandbox-admin";
tokensDir = "/var/lib/forgejo-sandbox/tokens";
stateDir = "/var/lib/forgejo-sandbox";
configDir = ./templates/reforge;
};
default = self.packages.${pkgs.system}.reforge-scripts;
});
# `nix run <this>#reforge-seed`, etc.
apps = forAllSystems (
pkgs:
nixpkgs.lib.genAttrs appNames (name: {
type = "app";
program = "${self.packages.${pkgs.system}.reforge-scripts}/bin/${name}";
})
);
checks = forAllSystems (
pkgs:
{
# Package derivations build (validates the wrapper + that agents/
# and settings/ are present and shipped).
forgejo-mcp = self.packages.${pkgs.system}.forgejo-mcp;
reforge-scripts = self.packages.${pkgs.system}.reforge-scripts;
}
// nixpkgs.lib.optionalAttrs (pkgs.stdenv.hostPlatform.isLinux) {
# Lightweight module-eval check: evaluate a NixOS system that
# imports the module with enable=true, then build only (a) the
# provisioning script the systemd unit runs and (b) the
# reforge-scripts CLI as the module wires it (with cfg.configDir
# etc. baked in). This forces the module's options, assertions,
# and both derivations to evaluate without building a full system
# toplevel.
module-eval =
let
sys = nixpkgs.lib.nixosSystem {
system = pkgs.system;
modules = [
self.nixosModules.reforge
{
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "nodev";
fsType = "ext4";
};
system.stateVersion = "24.11";
reforge.enable = true;
reforge.configDir = ./templates/reforge;
}
];
};
execStart = sys.config.systemd.services.reforge-provision.serviceConfig.ExecStart;
cliFromModule = builtins.filter (
p: (p.pname or "") == "reforge-scripts"
) sys.config.environment.systemPackages;
in
pkgs.runCommand "reforge-module-eval" { } (
''
mkdir -p "$out"
ln -sn ${execStart} "$out/provision-script"
''
+ nixpkgs.lib.concatMapStrings (p: ''ln -sn ${p} "$out/cli"'' + "\n") cliFromModule
);
}
);
formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style);
};
}

383
modules/reforge.nix Normal file
View file

@ -0,0 +1,383 @@
# reforge — local Forgejo instance + provisioned role accounts for a
# multi-agent dev-team simulation. See docs/architecture.md and
# docs/reforge.md.
#
# One Claude (or other agent) session per role (backend-dev, frontend-dev,
# security-lead, reviewer, ...), each driving its own Forgejo account via a
# scoped API token, with branch protection on every working repo requiring
# the security-lead's approval before anything merges to main. The point is
# review isolation: the security reviewer pulls the PR diff itself instead
# of being handed the implementer's rationale.
#
# STANDALONE-IMPORTABLE and DISABLED BY DEFAULT. Localhost-only by design:
# Forgejo binds 127.0.0.1 and the built-in SSH server stays off — every
# session talks HTTP with its token.
#
# This module OWNS `services.forgejo` on the host — it is a dedicated
# sandbox forge, not meant to coexist with another Forgejo instance on the
# same machine. HTTP defaults to :3030 (not :3000) so it does not squat the
# most common ad-hoc dev-server port.
#
# Provisioning is a oneshot that re-runs on every rebuild — every step is
# check-before-create, so it converges instead of failing on re-run. Tokens
# land in <stateDir>/tokens/<user>.token (0600, owned by cfg.tokenOwner) —
# the credential a role session gets is that one file, nothing else.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.reforge;
fcfg = config.services.forgejo;
stateDir = "/var/lib/forgejo-sandbox";
tokensDir = "${stateDir}/tokens";
forgejoMcp = cfg.forgejoMcpPackage;
# The reforge-* CLI wrappers, with this host's defaults baked in as
# REFORGE_* env so the operator just runs `reforge-seed`, `reforge-role
# <role>`, etc. Every default is overridable per-invocation by exporting
# the matching REFORGE_* variable.
reforgeScripts = pkgs.callPackage ../packages/reforge-scripts.nix {
inherit forgejoMcp;
forgeUrl = "http://localhost:${toString cfg.httpPort}";
org = cfg.org;
adminUser = cfg.adminUser;
inherit tokensDir stateDir;
configDir = toString cfg.configDir;
agentsDir = if cfg.agentsDir == null then null else toString cfg.agentsDir;
refsDir = if cfg.refsDir == null then null else toString cfg.refsDir;
};
# Branch protection payload: nobody pushes main directly; one approval
# required, and only approvals from requiredApprovers count toward it.
branchProtectionJson = builtins.toJSON {
branch_name = "main";
rule_name = "main";
enable_push = false;
required_approvals = 1;
enable_approvals_whitelist = true;
# Gitea/Forgejo API quirk: this field is SINGULAR (unlike
# push_whitelist_usernames) — the plural form is silently ignored,
# leaving an empty whitelist that no approval can ever satisfy.
approvals_whitelist_username = cfg.requiredApprovers;
block_on_rejected_reviews = true;
dismiss_stale_approvals = true;
};
provisionScript = pkgs.writeShellApplication {
name = "reforge-provision";
runtimeInputs = with pkgs; [
curl
jq
openssl
util-linux # setpriv
gawk
gnugrep
coreutils
];
text = ''
BASE="http://127.0.0.1:${toString cfg.httpPort}"
API="$BASE/api/v1"
TOKENS_DIR=${tokensDir}
RESP=$(mktemp)
trap 'rm -f "$RESP"' EXIT
# ── forgejo CLI, as the forgejo user (sqlite is owned by it) ──────
as_forgejo() {
setpriv --reuid=${fcfg.user} --regid=${fcfg.group} --clear-groups \
env GITEA_WORK_DIR=${fcfg.stateDir} HOME=${fcfg.stateDir} \
${lib.getExe fcfg.package} --config ${fcfg.stateDir}/custom/conf/app.ini "$@"
}
# ── admin API helpers ─────────────────────────────────────────────
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 $ADMIN_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 "reforge: $2 failed (HTTP $1):" >&2
cat "$RESP" >&2
exit 1
fi
}
# ── idempotent primitives ─────────────────────────────────────────
ensure_user() { # username [extra admin-user-create flags]
local u=$1
shift
if as_forgejo admin user list | awk 'NR>1 {print $2}' | grep -qx "$u"; then
return
fi
# Accounts are token-driven; the password is throwaway.
local pw
pw=$(openssl rand -hex 16)
as_forgejo admin user create --username "$u" --email "$u@sandbox.invalid" \
--password "$pw" --must-change-password=false "$@" >/dev/null
echo "created user $u"
}
ensure_token() { # username
local u=$1 f="$TOKENS_DIR/$1.token" tok
if [ -s "$f" ]; then return; fi
# Unique token name per generation: if the file was wiped but the
# server still holds an old token, a fixed name would collide.
tok=$(as_forgejo admin user generate-access-token --username "$u" \
--token-name "sandbox-$(date +%s%N)" --scopes all --raw)
(
umask 077
printf '%s\n' "$tok" >"$f"
)
chown ${cfg.tokenOwner}: "$f"
chmod 600 "$f"
echo "wrote token $f"
}
# ── wait for the API ──────────────────────────────────────────────
ready=
for _ in $(seq 1 60); do
if curl -sf "$BASE/api/healthz" >/dev/null 2>&1; then
ready=1
break
fi
sleep 2
done
if [ "$ready" != 1 ]; then
echo "reforge: API at $BASE never became healthy" >&2
exit 1
fi
# ── users + tokens ────────────────────────────────────────────────
ensure_user ${cfg.adminUser} --admin
ensure_token ${cfg.adminUser}
# UI login for the admin (role accounts stay token-only): the
# create-time password is throwaway, so persist a known one for
# the human to browse the web UI with. Set once, kept across runs.
PW_FILE=${stateDir}/admin-password
if [ ! -s "$PW_FILE" ]; then
pw=$(openssl rand -hex 12)
as_forgejo admin user change-password --username ${cfg.adminUser} \
--password "$pw" --must-change-password=false >/dev/null
(
umask 077
printf '%s\n' "$pw" >"$PW_FILE"
)
chown ${cfg.tokenOwner}: "$PW_FILE"
chmod 600 "$PW_FILE"
echo "wrote admin UI password to $PW_FILE"
fi
for u in ${lib.escapeShellArgs cfg.roles}; do
ensure_user "$u"
ensure_token "$u"
done
ADMIN_TOKEN=$(cat "$TOKENS_DIR/${cfg.adminUser}.token")
# ── org ───────────────────────────────────────────────────────────
if [ "$(api GET /orgs/${cfg.org})" = 404 ]; then
must "$(api POST /orgs '{"username":"${cfg.org}","visibility":"private"}')" \
"create org ${cfg.org}"
echo "created org ${cfg.org}"
fi
# ── team (write access for every role; review power comes from the
# branch-protection approver whitelist, not team permission) ────
must "$(api GET "/orgs/${cfg.org}/teams/search?q=crew")" "search teams"
tid=$(jq -r '.data[] | select(.name == "crew") | .id' <"$RESP" | head -n1)
if [ -z "$tid" ]; then
must "$(api POST /orgs/${cfg.org}/teams '{"name":"crew","permission":"write","includes_all_repositories":true,"units":["repo.code","repo.issues","repo.pulls","repo.releases"]}')" \
"create team crew"
tid=$(jq -r .id <"$RESP")
echo "created team crew (id $tid)"
fi
for u in ${lib.escapeShellArgs cfg.roles}; do
must "$(api PUT "/teams/$tid/members/$u")" "add $u to team crew"
done
# ── working repo ──────────────────────────────────────────────────
if [ "$(api GET /repos/${cfg.org}/${cfg.repoName})" = 404 ]; then
must "$(api POST /orgs/${cfg.org}/repos '{"name":"${cfg.repoName}","private":true,"auto_init":true,"default_branch":"main"}')" \
"create repo ${cfg.org}/${cfg.repoName}"
echo "created repo ${cfg.org}/${cfg.repoName}"
fi
# ── branch protection on main ─────────────────────────────────────
if [ "$(api GET /repos/${cfg.org}/${cfg.repoName}/branch_protections/main)" = 404 ]; then
must "$(api POST /repos/${cfg.org}/${cfg.repoName}/branch_protections '${branchProtectionJson}')" \
"protect main"
echo "protected main (required approver(s): ${lib.concatStringsSep ", " cfg.requiredApprovers})"
fi
echo "reforge: provisioning converged."
'';
};
in
{
options.reforge = {
enable = lib.mkEnableOption "Local Forgejo sandbox for multi-agent dev-team simulation (reforge)";
httpPort = lib.mkOption {
type = lib.types.port;
default = 3030;
description = "Localhost HTTP port for the sandbox Forgejo (3000 is left free for ad-hoc dev servers).";
};
org = lib.mkOption {
type = lib.types.str;
default = "sandbox-team";
description = "Organization the role accounts and working repos live under.";
};
repoName = lib.mkOption {
type = lib.types.str;
default = "sandbox-project";
description = "Working repository created under the org (auto-init, main protected). The smoke test exercises this repo.";
};
roles = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"backend-dev"
"frontend-dev"
"security-lead"
"reviewer"
];
description = ''
Role usernames to provision. Each gets an account + API token; one
agent session per role. There must be a matching brief
<agentsDir>/<role>.md for every role you launch.
'';
};
requiredApprovers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "security-lead" ];
description = ''
Usernames whose approval counts toward the required-approvals gate
on main (approvals whitelist). Must be a subset of roles.
'';
};
adminUser = lib.mkOption {
type = lib.types.str;
default = "sandbox-admin";
description = "Instance admin account used by the provisioning script's API calls and the seed/kickoff scripts.";
};
tokenOwner = lib.mkOption {
type = lib.types.str;
default = "root";
description = ''
Local account that owns the generated token files under
${tokensDir} (mode 0600). Set to the human user who will launch the
per-role agent sessions.
'';
};
configDir = lib.mkOption {
type = lib.types.path;
description = ''
Instance run configuration: a directory containing manifest.txt
(repo set, pinned bases, declared targets), charter.md (the
standard changes are judged against), agenda.md (this run's
worklist), and issues.tsv (agenda items to file as issues at
kickoff). This is the per-project data an adopter fills in see
the `reforge` flake template. Baked into the store, so a change is
a rebuild.
'';
};
agentsDir = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Directory of agent briefs injected as each session's CLAUDE.md:
common.md (shared preamble, @ROLE@-templated), one <role>.md per
role, and orchestrator.md (the autonomous-driver playbook). Null
uses the engine's generic briefs; set it to your own directory to
customize the role framing for your project.
'';
};
refsDir = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Optional read-only reference corpus (mirrors of well-known
codebases) agents may cite. Null omits the refs instruction from
briefs entirely. Not baked into the store passed as a runtime
path (REFORGE_REFS_DIR) since it is typically large and mutable.
'';
};
forgejoMcpPackage = lib.mkOption {
type = lib.types.package;
default = pkgs.callPackage ../packages/forgejo-mcp.nix { };
defaultText = lib.literalExpression "pkgs.callPackage ../packages/forgejo-mcp.nix { }";
description = "The forgejo-mcp server binary sessions use to drive the forge over MCP.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = lib.all (u: lib.elem u cfg.roles) cfg.requiredApprovers;
message = "reforge.requiredApprovers must be a subset of reforge.roles approvers need provisioned accounts.";
}
];
environment.systemPackages = [
forgejoMcp
reforgeScripts
];
services.forgejo = {
enable = true;
database.type = "sqlite3"; # single host, a handful of sequential sessions
# stateDir defaults to /var/lib/forgejo — real persisted state,
# survives rebuilds; StateDirectory is managed by the upstream module.
settings = {
server = {
DOMAIN = "localhost";
HTTP_ADDR = "127.0.0.1"; # sandbox: loopback only, no firewall hole
HTTP_PORT = cfg.httpPort;
ROOT_URL = "http://localhost:${toString cfg.httpPort}/";
DISABLE_SSH = true; # tokens over HTTP; no SSH server on the sandbox
};
service.DISABLE_REGISTRATION = true; # accounts come from provisioning only
repository.DEFAULT_BRANCH = "main";
};
};
systemd.tmpfiles.rules = [
"d ${stateDir} 0751 root root -"
"d ${tokensDir} 0700 ${cfg.tokenOwner} - -"
];
# Oneshot, re-run on every switch; every step converges (check-before-
# create), so RemainAfterExit + wantedBy multi-user is safe to leave on.
systemd.services.reforge-provision = {
description = "Provision reforge role accounts, tokens, org, repo, branch protection";
after = [ "forgejo.service" ];
requires = [ "forgejo.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = lib.getExe provisionScript;
};
};
};
}

42
packages/forgejo-mcp.nix Normal file
View file

@ -0,0 +1,42 @@
# packages/forgejo-mcp.nix
#
# forgejo-mcp — Model Context Protocol server for the Forgejo REST API.
# Upstream: https://codeberg.org/goern/forgejo-mcp (de-facto community
# implementation; no official forgejo/forgejo-mcp org repo exists yet).
# Sessions use this binary to drive the sandbox forge over MCP.
{
lib,
buildGoModule,
fetchFromGitea,
}:
buildGoModule rec {
pname = "forgejo-mcp";
version = "2.17.0";
src = fetchFromGitea {
domain = "codeberg.org";
owner = "goern";
repo = "forgejo-mcp";
rev = "v${version}";
hash = "sha256-DcpS2467MCFfIVsdYEfd5t6kPjMeLElMQbDyuXI04XE=";
};
vendorHash = "sha256-5CV4drUaYKtZ/RoydAatblhsqU8VWYzYByjhcb9KZVY=";
subPackages = [ "." ];
ldflags = [
"-s"
"-w"
];
doCheck = false;
meta = with lib; {
description = "Model Context Protocol server for Forgejo REST API";
homepage = "https://codeberg.org/goern/forgejo-mcp";
license = licenses.asl20;
mainProgram = "forgejo-mcp";
};
}

View file

@ -0,0 +1,103 @@
# packages/reforge-scripts.nix
#
# The reforge-* operator CLI: the lifecycle scripts (scripts/*.sh) wrapped
# as `reforge-<name>` commands with this host's defaults baked in as
# REFORGE_* environment variables, plus the runtime tools they need on
# PATH. Every default is overridable per-invocation by exporting the
# matching REFORGE_* variable before running.
#
# The engine's generic agent briefs (agents/) and permission policies
# (scripts/settings/) are shipped under $out/share/reforge and referenced
# by default; override with agentsDir / REFORGE_AGENTS_DIR to customize the
# role framing for your project.
{
lib,
stdenvNoCC,
makeWrapper,
bash,
coreutils,
git,
curl,
jq,
gnugrep,
gawk,
gnused,
openssh,
gnutar,
gzip,
findutils,
# env defaults (supplied by modules/reforge.nix from its options)
forgejoMcp,
forgeUrl,
org,
adminUser,
tokensDir,
stateDir,
configDir,
agentsDir ? null,
refsDir ? null,
}:
let
scriptsDir = ../scripts;
runtimePath = lib.makeBinPath [
bash
coreutils
git
curl
jq
gnugrep
gawk
gnused
openssh
gnutar
gzip
findutils
];
in
stdenvNoCC.mkDerivation {
pname = "reforge-scripts";
version = "0.1.0";
dontUnpack = true;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/bin $out/share/reforge
cp -r ${../scripts/settings} $out/share/reforge/settings
cp -r ${../agents} $out/share/reforge/agents
AGENTS=${
if agentsDir != null then lib.escapeShellArg (toString agentsDir) else "$out/share/reforge/agents"
}
SETTINGS=$out/share/reforge/settings
for f in ${scriptsDir}/*.sh; do
name=$(basename "$f" .sh)
makeWrapper ${bash}/bin/bash "$out/bin/$name" \
--add-flags "$f" \
--prefix PATH : "${runtimePath}" \
--set-default REFORGE_MCP_BIN ${forgejoMcp}/bin/forgejo-mcp \
--set-default REFORGE_FORGE_URL ${lib.escapeShellArg forgeUrl} \
--set-default REFORGE_ORG ${lib.escapeShellArg org} \
--set-default REFORGE_ADMIN_USER ${lib.escapeShellArg adminUser} \
--set-default REFORGE_TOKENS_DIR ${lib.escapeShellArg tokensDir} \
--set-default REFORGE_STATE_DIR ${lib.escapeShellArg stateDir} \
--set-default REFORGE_CONFIG_DIR ${lib.escapeShellArg (toString configDir)} \
--set-default REFORGE_SETTINGS_DIR "$SETTINGS" \
--set-default REFORGE_AGENTS_DIR "$AGENTS" \
${lib.optionalString (
refsDir != null
) "--set-default REFORGE_REFS_DIR ${lib.escapeShellArg (toString refsDir)}"}
done
runHook postInstall
'';
meta = with lib; {
description = "reforge lifecycle CLI (seed/reset/compare/smoke/kickoff/role/orchestrator/fetch-targets)";
mainProgram = "reforge-seed";
license = licenses.mit;
};
}

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"
]
}
}

View file

@ -0,0 +1,33 @@
# A reforge run
This directory is one reforge run's configuration. Edit the four data files,
wire the module into a NixOS host, and drive the run. See the engine's
`docs/reforge.md` for the full concept and lifecycle.
## The four files you edit
| File | What it is |
|---|---|
| `manifest.txt` | The repo set: pinned bases (`fork`) + placeholders (`original`), and each repo's declared target for the acceptance diff. |
| `charter.md` | The standard every change is judged against. Seeded as `charter/README.md`; reviews cite it. |
| `agenda.md` | What this run must address, in item-id'd groups. |
| `issues.tsv` | The agenda as `repo\|title\|body` rows — filed as issues by `reforge-kickoff`. Keep in sync with `agenda.md`. |
## Wire it up
1. Edit `flake.nix`: set `tokenOwner`, point `reforge.url` at the engine.
2. Add the module to your NixOS host: `imports = [ inputs.myreforge.nixosModules.default ];`
3. `nixos-rebuild switch` — you now have the sandbox forge on
`http://localhost:3030`, provisioned role accounts, and the `reforge-*`
CLI on PATH.
## Drive a run
```bash
reforge-smoke # verify the toolchain + gates after a (re)build
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 …
# or let one agent drive the whole thing:
reforge-orchestrator # then: "Drive Phase A to completion."
```

View file

@ -0,0 +1,32 @@
# Run agenda — <run name>
Everything this particular run must address. Group items and give each a
short id (`A1`, `B2`, …) so issues and reviews can trace back to it. The
machine-readable twin of this file is `issues.tsv` (what `reforge-kickoff`
files as issues) — keep the two in sync.
## A. Production-readiness
- **A1** — Security audit per repo (auth, key custody, secrets, injection).
- **A2** — Bug hunt on the critical paths.
- **A3** — Refactor opportunities that are cheap now, expensive after
release (strict-from-the-start — no compatibility shims pre-launch).
- **A4** — Test-coverage review against the charter's test harness.
- **A5** — Synthesize `GAMEPLAN.md`: what stands between this stack and
production-ready, ordered, with owners. (PR into the charter repo.)
## B. <project-specific arc>
- *e.g. an architectural evolution the run should evaluate and stage as
tracked divergences in Phase B.*
## C. Coherence
- **C1** — Map every inter-component contract (who calls whom, over what).
- **C2** — Check each contract against the charter's boundary principles.
## Outputs (deliverables of the run)
- Issues traceable to these agenda items.
- Reviews on every PR (security + charter, plus any project lens).
- `GAMEPLAN.md` PR'd into the charter repo.

View file

@ -0,0 +1,45 @@
# Charter — <your project>
This is the standard every change in the run is judged against. It is seeded
into the forge as `@ORG@/charter` (README.md) and every review should cite
it. Fill in the sections below; delete the guidance italics.
## Mission
*What is this stack, and what is the run trying to achieve? One paragraph.*
## Principles (what "aligned" means)
*The rules a change must respect — architecture, dependencies, boundaries,
security posture, self-hostability, protocol choices, licensing. The
`reviewer` role enforces these as the "charter gate", so be concrete: a
principle a reviewer can't check is decoration.*
- *e.g. No closed/hosted control-plane dependencies — everything must be
self-hostable.*
- *e.g. Prefer a standard wire protocol over private in-process coupling
between components that might not always be colocated.*
- *e.g. Secrets never land in code or config; demo/test gates must never be
reachable in a production posture.*
## Intent docs (Phase A sources)
*In Phase A the target repos are off-limits — roles work from recorded
intent. List the docs that intent lives in (roadmaps, ADRs, vision notes)
and where to find them. If a reference corpus is available, say so and note
that its path is in `$REFORGE_REFS_DIR`.*
## Test harness
*How does a role run tests inside the sandbox? Name the commands, any local
services, and what needs the human to arrange (real infra a role can't
reach).*
## Review lenses
- **security-lead** — the merge gate. *List the stack-specific hotspots to
threat-model and the demo/env gates that must never reach production.*
- **reviewer** — quality + charter alignment (this document).
- *Add project-specific reviewer roles here (e.g. a protocol-conformance
lens). For each: add its brief as `<role>.md` in your agents dir, add its
username to `reforge.roles`, and describe its lens here.*

View file

@ -0,0 +1,51 @@
{
description = "A reforge run configuration (manifest + charter + agenda), wiring the reforge engine as a NixOS module.";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# The reforge engine. Point this at wherever the engine lives — the
# aiolabs Forgejo by default; a GitHub mirror works the same way.
reforge.url = "git+https://git.atitlan.io/aiolabs/claude-forgejo-sandbox";
reforge.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{ self, nixpkgs, reforge }:
{
# Import this module into your NixOS host's module list. It turns on
# the sandbox forge and points it at THIS directory as the run config
# (manifest.txt / charter.md / agenda.md / issues.tsv live here).
#
# # in your host config:
# imports = [ inputs.myreforge.nixosModules.default ];
#
# Then `nixos-rebuild switch`, and you get the reforge-* CLI on PATH.
# See the engine's docs/reforge.md for the run lifecycle.
nixosModules.default =
{ ... }:
{
imports = [ reforge.nixosModules.reforge ];
reforge = {
enable = true;
configDir = ./.;
# The local account that owns the generated token files and
# launches the per-role agent sessions. CHANGE THIS.
tokenOwner = "youruser";
# Roles to provision (one agent session each). There must be a
# brief <agentsDir>/<role>.md for every role here.
# roles = [ "backend-dev" "frontend-dev" "security-lead" "reviewer" ];
# Uncomment to customize the role briefs / orchestrator playbook
# instead of using the engine's generic ones:
# agentsDir = ./agents;
# Optional read-only reference corpus agents may cite:
# refsDir = "/home/youruser/refs";
};
};
};
}

View file

@ -0,0 +1,10 @@
# Agenda-as-issues — filed by reforge-kickoff into the sandbox repos.
# repo|title|body
# ('#' and blank lines ignored; body is single-line markdown). Titles should
# carry the agenda item id for traceability. `repo` must exist in the forge
# (charter, the working repo, or a manifest repo).
#
# ── EXAMPLE ROWS (replace with your agenda) ───────────────────────────
charter|[A5] Synthesize GAMEPLAN.md|Collect the release criteria emerging from the A1-A4 issues across all repos and synthesize the run's game plan: what stands between this stack and production-ready, ordered, with owners. Deliverable: a PR adding GAMEPLAN.md to this repo. Phase A rules apply.
hello|[A1] Security audit at the seeded base|Audit the seeded base for the usual hotspots (auth, secrets, input handling, dependencies). File findings as sub-issues or a checklist here. Phase A rules apply: work from recorded intent; the target repo is off-limits.
myapp|[scaffold] Rebuild from recorded intent|Phase A epic: scaffold this component per the charter + agenda. Break into sub-issues before implementing. Phase A rules apply.
1 # Agenda-as-issues — filed by reforge-kickoff into the sandbox repos.
2 # repo|title|body
3 # ('#' and blank lines ignored; body is single-line markdown). Titles should
4 # carry the agenda item id for traceability. `repo` must exist in the forge
5 # (charter, the working repo, or a manifest repo).
6 #
7 # ── EXAMPLE ROWS (replace with your agenda) ───────────────────────────
8 charter|[A5] Synthesize GAMEPLAN.md|Collect the release criteria emerging from the A1-A4 issues across all repos and synthesize the run's game plan: what stands between this stack and production-ready, ordered, with owners. Deliverable: a PR adding GAMEPLAN.md to this repo. Phase A rules apply.
9 hello|[A1] Security audit at the seeded base|Audit the seeded base for the usual hotspots (auth, secrets, input handling, dependencies). File findings as sub-issues or a checklist here. Phase A rules apply: work from recorded intent; the target repo is off-limits.
10 myapp|[scaffold] Rebuild from recorded intent|Phase A epic: scaffold this component per the charter + agenda. Break into sub-issues before implementing. Phase A rules apply.

View file

@ -0,0 +1,25 @@
# Reforge run manifest — read by reforge-seed and reforge-compare.
#
# name|kind|upstream|base_ref|target_url|target_ref
#
# kind fork = sandbox repo seeded from `upstream` at `base_ref`
# original = empty placeholder repo (your own software)
# base_ref branch, tag, or commit SHA of the upstream to seed as `main`.
# "auto" = upstream default-branch tip — convenient for exploring,
# NOT reproducible: pin every fork before a real run.
# target_* the known working state (real repo + branch) the run must
# reproduce byte-for-byte. NEVER pushed into the sandbox — only
# reforge-compare reads it, and reforge-fetch-targets mirrors it
# for Phase B. "-" = not declared (compare skips the repo).
#
# Changing base_ref does not re-seed an existing repo (seeding is
# skip-if-pushed) — reset the sandbox first (reforge-reset reset).
#
# ── EXAMPLE ROWS (replace with your stack) ────────────────────────────
# A fork seeded from a pinned upstream commit, no target declared yet:
hello|fork|https://github.com/octocat/Hello-World|7fd1a60b01f91b314f59955a4e4d4e80d8edf11d|-|-
# Your own software: an empty placeholder to rebuild from recorded intent.
# Declare a target once you have a known-working branch to converge to:
myapp|original|-|-|-|-
# myapp|original|-|-|git@github.com:you/myapp|main