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:
commit
df0fd9a9ba
32 changed files with 2698 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
.direnv/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
81
README.md
Normal 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
15
agents/backend-dev.md
Normal 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
66
agents/common.md
Normal 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
16
agents/frontend-dev.md
Normal 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
215
agents/orchestrator.md
Normal 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
23
agents/reviewer.md
Normal 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
22
agents/security-lead.md
Normal 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
142
docs/architecture.md
Normal 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
191
docs/reforge.md
Normal 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 3–5 (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
27
flake.lock
generated
Normal 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
126
flake.nix
Normal 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
383
modules/reforge.nix
Normal 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
42
packages/forgejo-mcp.nix
Normal 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";
|
||||||
|
};
|
||||||
|
}
|
||||||
103
packages/reforge-scripts.nix
Normal file
103
packages/reforge-scripts.nix
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
73
scripts/reforge-compare.sh
Normal file
73
scripts/reforge-compare.sh
Normal 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"
|
||||||
104
scripts/reforge-fetch-targets.sh
Normal file
104
scripts/reforge-fetch-targets.sh
Normal 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; }
|
||||||
80
scripts/reforge-kickoff.sh
Normal file
80
scripts/reforge-kickoff.sh
Normal 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 ]
|
||||||
72
scripts/reforge-orchestrator.sh
Normal file
72
scripts/reforge-orchestrator.sh
Normal 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
93
scripts/reforge-reset.sh
Normal 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
91
scripts/reforge-role.sh
Normal 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
151
scripts/reforge-seed.sh
Normal 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
165
scripts/reforge-smoke.sh
Normal 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 ]
|
||||||
64
scripts/settings/orchestrator-settings.json
Normal file
64
scripts/settings/orchestrator-settings.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
133
scripts/settings/role-settings.json
Normal file
133
scripts/settings/role-settings.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
33
templates/reforge/README.md
Normal file
33
templates/reforge/README.md
Normal 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."
|
||||||
|
```
|
||||||
32
templates/reforge/agenda.md
Normal file
32
templates/reforge/agenda.md
Normal 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.
|
||||||
45
templates/reforge/charter.md
Normal file
45
templates/reforge/charter.md
Normal 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.*
|
||||||
51
templates/reforge/flake.nix
Normal file
51
templates/reforge/flake.nix
Normal 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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
10
templates/reforge/issues.tsv
Normal file
10
templates/reforge/issues.tsv
Normal 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.
|
||||||
|
25
templates/reforge/manifest.txt
Normal file
25
templates/reforge/manifest.txt
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue