From df0fd9a9bac1c9df9b554f5adf7cc6161288e891 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 18 Jul 2026 22:50:49 +0200 Subject: [PATCH] feat: extract reforge engine into a standalone consumable flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + LICENSE | 21 ++ README.md | 81 +++++ agents/backend-dev.md | 15 + agents/common.md | 66 ++++ agents/frontend-dev.md | 16 + agents/orchestrator.md | 215 +++++++++++ agents/reviewer.md | 23 ++ agents/security-lead.md | 22 ++ docs/architecture.md | 142 ++++++++ docs/reforge.md | 191 ++++++++++ flake.lock | 27 ++ flake.nix | 126 +++++++ modules/reforge.nix | 383 ++++++++++++++++++++ packages/forgejo-mcp.nix | 42 +++ packages/reforge-scripts.nix | 103 ++++++ scripts/reforge-compare.sh | 73 ++++ scripts/reforge-fetch-targets.sh | 104 ++++++ scripts/reforge-kickoff.sh | 80 ++++ scripts/reforge-orchestrator.sh | 72 ++++ scripts/reforge-reset.sh | 93 +++++ scripts/reforge-role.sh | 91 +++++ scripts/reforge-seed.sh | 151 ++++++++ scripts/reforge-smoke.sh | 165 +++++++++ scripts/settings/orchestrator-settings.json | 64 ++++ scripts/settings/role-settings.json | 133 +++++++ templates/reforge/README.md | 33 ++ templates/reforge/agenda.md | 32 ++ templates/reforge/charter.md | 45 +++ templates/reforge/flake.nix | 51 +++ templates/reforge/issues.tsv | 10 + templates/reforge/manifest.txt | 25 ++ 32 files changed, 2698 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 agents/backend-dev.md create mode 100644 agents/common.md create mode 100644 agents/frontend-dev.md create mode 100644 agents/orchestrator.md create mode 100644 agents/reviewer.md create mode 100644 agents/security-lead.md create mode 100644 docs/architecture.md create mode 100644 docs/reforge.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 modules/reforge.nix create mode 100644 packages/forgejo-mcp.nix create mode 100644 packages/reforge-scripts.nix create mode 100644 scripts/reforge-compare.sh create mode 100644 scripts/reforge-fetch-targets.sh create mode 100644 scripts/reforge-kickoff.sh create mode 100644 scripts/reforge-orchestrator.sh create mode 100644 scripts/reforge-reset.sh create mode 100644 scripts/reforge-role.sh create mode 100644 scripts/reforge-seed.sh create mode 100644 scripts/reforge-smoke.sh create mode 100644 scripts/settings/orchestrator-settings.json create mode 100644 scripts/settings/role-settings.json create mode 100644 templates/reforge/README.md create mode 100644 templates/reforge/agenda.md create mode 100644 templates/reforge/charter.md create mode 100644 templates/reforge/flake.nix create mode 100644 templates/reforge/issues.tsv create mode 100644 templates/reforge/manifest.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce7b4b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +result +result-* +.direnv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc60ed4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..feb75a3 --- /dev/null +++ b/README.md @@ -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..forgejo-mcp` | The MCP server binary | +| `packages..reforge-scripts` | The `reforge-*` CLI (standalone, template defaults) | +| `apps..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). diff --git a/agents/backend-dev.md b/agents/backend-dev.md new file mode 100644 index 0000000..bde5f91 --- /dev/null +++ b/agents/backend-dev.md @@ -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/-` (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. diff --git a/agents/common.md b/agents/common.md new file mode 100644 index 0000000..506acc7 --- /dev/null +++ b/agents/common.md @@ -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/` + (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@/.git" + cd && 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. diff --git a/agents/frontend-dev.md b/agents/frontend-dev.md new file mode 100644 index 0000000..56d6f1c --- /dev/null +++ b/agents/frontend-dev.md @@ -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/-` (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. diff --git a/agents/orchestrator.md b/agents/orchestrator.md new file mode 100644 index 0000000..4dcb4d4 --- /dev/null +++ b/agents/orchestrator.md @@ -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 " — 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 -p "" +``` + +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 and +work it."* / *"Review the open PR #N on ."* / *"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/` (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-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. diff --git a/agents/reviewer.md b/agents/reviewer.md new file mode 100644 index 0000000..2ff6685 --- /dev/null +++ b/agents/reviewer.md @@ -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. diff --git a/agents/security-lead.md b/agents/security-lead.md new file mode 100644 index 0000000..2727cf0 --- /dev/null +++ b/agents/security-lead.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c0798e0 --- /dev/null +++ b/docs/architecture.md @@ -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/.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 # backend-dev | frontend-dev | security-lead | reviewer | … +``` + +The launcher prepares `$REFORGE_TEAM_DIR//` (default `~/sandbox-team//`) +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` + `.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 `.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. diff --git a/docs/reforge.md b/docs/reforge.md new file mode 100644 index 0000000..ecde06e --- /dev/null +++ b/docs/reforge.md @@ -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/`, + 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 + (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 + 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 + (or go straight to the next run's reset, which archives too) +``` + +Archives: `/var/lib/forgejo-sandbox-archive/-.tar.gz`; restore +any of them with `reforge-reset restore `. + +## 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. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..301a9a7 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..008fb13 --- /dev/null +++ b/flake.nix @@ -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 #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 #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); + }; +} diff --git a/modules/reforge.nix b/modules/reforge.nix new file mode 100644 index 0000000..963bb87 --- /dev/null +++ b/modules/reforge.nix @@ -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 /tokens/.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 + # `, 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 + /.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 .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; + }; + }; + }; +} diff --git a/packages/forgejo-mcp.nix b/packages/forgejo-mcp.nix new file mode 100644 index 0000000..046ad3e --- /dev/null +++ b/packages/forgejo-mcp.nix @@ -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"; + }; +} diff --git a/packages/reforge-scripts.nix b/packages/reforge-scripts.nix new file mode 100644 index 0000000..eb6ca8d --- /dev/null +++ b/packages/reforge-scripts.nix @@ -0,0 +1,103 @@ +# packages/reforge-scripts.nix +# +# The reforge-* operator CLI: the lifecycle scripts (scripts/*.sh) wrapped +# as `reforge-` 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; + }; +} diff --git a/scripts/reforge-compare.sh b/scripts/reforge-compare.sh new file mode 100644 index 0000000..36b0464 --- /dev/null +++ b/scripts/reforge-compare.sh @@ -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" diff --git a/scripts/reforge-fetch-targets.sh b/scripts/reforge-fetch-targets.sh new file mode 100644 index 0000000..d324e15 --- /dev/null +++ b/scripts/reforge-fetch-targets.sh @@ -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/ (default ~/reforge-targets/) +# as the end-state; provenance is in each /.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" </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; } diff --git a/scripts/reforge-kickoff.sh b/scripts/reforge-kickoff.sh new file mode 100644 index 0000000..c74681c --- /dev/null +++ b/scripts/reforge-kickoff.sh @@ -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 ] diff --git a/scripts/reforge-orchestrator.sh b/scripts/reforge-orchestrator.sh new file mode 100644 index 0000000..bf90b89 --- /dev/null +++ b/scripts/reforge-orchestrator.sh @@ -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" < put an archived run back +# +# Archives land in /var/lib/forgejo-sandbox-archive/-.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 " >&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 diff --git a/scripts/reforge-role.sh b/scripts/reforge-role.sh new file mode 100644 index 0000000..164b28e --- /dev/null +++ b/scripts/reforge-role.sh @@ -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 [agent args...] +# +# Prepares $REFORGE_TEAM_DIR// 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,}.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 [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" < 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" diff --git a/scripts/reforge-smoke.sh b/scripts/reforge-smoke.sh new file mode 100644 index 0000000..3154e93 --- /dev/null +++ b/scripts/reforge-smoke.sh @@ -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 ] diff --git a/scripts/settings/orchestrator-settings.json b/scripts/settings/orchestrator-settings.json new file mode 100644 index 0000000..8d5005e --- /dev/null +++ b/scripts/settings/orchestrator-settings.json @@ -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" + ] + } +} diff --git a/scripts/settings/role-settings.json b/scripts/settings/role-settings.json new file mode 100644 index 0000000..fb25251 --- /dev/null +++ b/scripts/settings/role-settings.json @@ -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" + ] + } +} diff --git a/templates/reforge/README.md b/templates/reforge/README.md new file mode 100644 index 0000000..61472e2 --- /dev/null +++ b/templates/reforge/README.md @@ -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." +``` diff --git a/templates/reforge/agenda.md b/templates/reforge/agenda.md new file mode 100644 index 0000000..1f146d1 --- /dev/null +++ b/templates/reforge/agenda.md @@ -0,0 +1,32 @@ +# Run agenda — + +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. + +- *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. diff --git a/templates/reforge/charter.md b/templates/reforge/charter.md new file mode 100644 index 0000000..055fd9e --- /dev/null +++ b/templates/reforge/charter.md @@ -0,0 +1,45 @@ +# Charter — + +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 `.md` in your agents dir, add its + username to `reforge.roles`, and describe its lens here.* diff --git a/templates/reforge/flake.nix b/templates/reforge/flake.nix new file mode 100644 index 0000000..b39dca0 --- /dev/null +++ b/templates/reforge/flake.nix @@ -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 /.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"; + }; + }; + }; +} diff --git a/templates/reforge/issues.tsv b/templates/reforge/issues.tsv new file mode 100644 index 0000000..a7ccf5b --- /dev/null +++ b/templates/reforge/issues.tsv @@ -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. diff --git a/templates/reforge/manifest.txt b/templates/reforge/manifest.txt new file mode 100644 index 0000000..ce69458 --- /dev/null +++ b/templates/reforge/manifest.txt @@ -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