Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,21 @@ jobs:
run: |
python -m pip install --quiet pre-commit pytest pyyaml
python -B -m pytest -q -p no:cacheprovider scripts/test_precommit_hook.py

recipe-ruflo:
name: recipe (ruflo)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
Comment on lines +217 to +220
- name: Install recipe dependencies
# No npm, no Node, no credentials: ruflo has no Python package, and a live
# swarm needs the `claude` binary plus model spend. The example replays the
# committed examples/ruflo-gate/fixture/ recording instead — the gate,
# projection, emit, doctor and metrics path all still execute for real.
# The opt-in live schema-drift alarm (LOOP_RUFLO_LIVE=1) stays skipped here.
run: python -m pip install --upgrade pip pyyaml pytest jsonschema
- name: ruflo recipe end-to-end
run: python -B -m pytest -q -p no:cacheprovider scripts/test_ruflo_recipe.py
146 changes: 146 additions & 0 deletions docs/integrations/ruflo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# ruflo — swarm below, acceptance gate above

[ruflo](https://github.com/ruvnet/ruflo) owns the ORCHESTRATE tier: a multi-agent
swarm that spawns a Queen coordinator and worker agents over the Claude Code CLI,
runs the SPARC phases, and keeps its state in `.swarm/` and `.hive-mind/`. What it
cannot do is tell you whether the objective was actually met — a swarm's terminal
is "the coordinator decided", recorded as a child-process exit code plus rows the
swarm wrote about itself. Loop Engineer adds the tier *above* it: one acceptance
gate the swarm as a whole must pass. It never replaces ruflo; it certifies what
the swarm produced.

## The seam: a host-side supervisor, not a swarm hook

`ruflo hive-mind spawn "<objective>" --claude` **blocks** — it spawns the `claude`
binary as the swarm's execution body and awaits its exit, mapping `exit 0` to
success. So the integration point is the process you already control: the
supervisor that launches the CLI and reads the run directory afterwards.

> There is **no swarm-terminal callback to register.** ruflo's `hooks` subcommands
> are calls *into* its learning system (`ruflo hooks post-task …`), and the plugin
> `HookEvent` enum carries no swarm-level terminal event — the `swarm:consensus-reached`
> / `task:post-complete` names in the docs do not exist in the implementation.
> Gate from the outside instead. For a second, independent gate *inside* the
> swarm's child, register [`hooks/stop_firewall.py`](../../hooks/stop_firewall.py)
> as a Claude Code `Stop` hook: it blocks a turn that ends on a `Succeeded` claim
> `loop doctor` disagrees with.

## The pattern

```python
from loop import emit
from loop.integrations import EngineOutcome, to_terminal_state

run = subprocess.run(["npx", "ruflo@3.32.9", "hive-mind", "spawn", objective,
"--claude", "--non-interactive"], cwd=ws) # blocks

obs = observe(ws) # .swarm/ JSON + memory export
gate = holdout_gate.decide(visible, holdout) # the split the swarm never saw
ac = anticheat_scan.scan(diff_text=git_diff, trajectory=agent_trails(obs))

criteria_met = {cid: proven.get(cid) for cid in declared_criteria(obs["export"])}
terminal = to_terminal_state(
outcome=EngineOutcome(
reached_end=run.returncode == 0 and obs["state"]["status"] in {"ready", "initialized", "stopped"},
human_abort=supervisor_was_interrupted(), # NEVER inferred from the exit code
artifacts=[...],
),
gate_verdict=gate, anticheat=ac, criteria_met=criteria_met,
)
emit.terminate(ws, state=terminal["state"], criteria_met=terminal["criteria_met"],
evidence=terminal["evidence"], false_completion=terminal["false_completion"],
reason=terminal["reason"], iteration_id=1)
```

`loop/integrations.py` needs **no ruflo-specific code** — every input is host-side
observable, and ruflo itself is unmodified.

## What a supervisor can read (zero ruflo changes)

| Signal | Shape |
|---|---|
| exit code of `hive-mind spawn … --claude` | `0` == the swarm's self-report of success |
| `.swarm/state.json` | `{id, topology, maxAgents, strategy, v3Mode, initializedAt, status}` |
| `.swarm/tasks/*.json` | per-task `status` ∈ `completed|done|in_progress|running|pending` |
| `.swarm/agents/*.json`, `.swarm/coordination/*.json` | the agent trails and consensus rows |
| `ruflo swarm status --format json` | live counts, progress, metrics |
| `ruflo memory export -o <file>` | the SPARC namespaces, incl. declared `acceptanceCriteria` |
| `ruflo autopilot status --json` | re-engagement loop state (`--max-iterations`, `--timeout`) |

Authoritative run state lives in binary SQLite (`.swarm/memory.db`,
`.hive-mind/hive.db`). Never parse those — `memory export` is the supported
serialization. Note the flag inconsistency: `swarm status --format json` but
`autopilot status --json`.

### Three traps

1. **Ctrl-C exits 0.** ruflo's SIGINT path prints "Pausing session", kills the
child and calls `process.exit(0)` — an interrupted run is indistinguishable
from a successful one by exit code. `AbortedByHuman` must come from the
supervisor's own signal handler; never derive it from ruflo.
2. **`ruflo verify` is install-integrity, not a run verdict.** It checks the
SHA-256 + Ed25519 witness of the *installed artifact* against
`verification.md.json`. It says nothing about whether the objective was met —
wiring it as the gate would certify that the package downloaded correctly.
3. **`sparc-gates` is the swarm grading its own homework.** The memory export's
`sparc-gates` namespace records per-phase `pass` rows and a `truthScore`. Read
the `sparc-phases` `acceptanceCriteria` as a criteria *vocabulary*, record the
self-report as observation — and let the withheld holdout gate decide.

## ruflo signal → typed terminal state

| ruflo signal | Typed terminal state |
|---|---|
| exit 0, swarm settled, holdout green + anticheat clean, every declared criterion proven | `Succeeded` |
| exit 0, visible green / holdout red | `FailedUnverifiable` (`false_completion: true`) |
| the swarm declared an AC no check covers | `FailedSpecGap` |
| autopilot `--max-iterations` / `--timeout` reached without a green gate | `FailedBudget` |
| non-zero exit with no completed tasks (MCP / provider / credential failure) | `FailedBlocked` |
| operator interrupt, recorded by the supervisor | `AbortedByHuman` |
| anticheat CRITICAL (gate tampering) | `FailedSafety` |

Precedence is `to_terminal_state`'s fixed order — safety → human → blocked →
budget → spec-gap → gate — so an interrupted or gamed run can never launder
itself into `Succeeded`.

## Zero-install mode

The `loop.integrations` module is convenience, not a requirement — the whole
projection is the SAME ~15 lines as the LangGraph and Temporal recipes (the
adapter is engine-neutral):

```python
def to_terminal(gate, anticheat, criteria_met, evidence,
*, human_abort=False, blocked=None, over_budget=False):
fc = gate.get("false_completion") is True
if anticheat.get("downgrade_to") == "FailedSafety": state = "FailedSafety"
elif human_abort: state = "AbortedByHuman"
elif blocked: state = "FailedBlocked"
elif over_budget: state = "FailedBudget"
elif any(v is None for v in criteria_met.values()): state = "FailedSpecGap"
elif (not gate or not anticheat or anticheat.get("downgrade_to")
or gate.get("verdict") != "Succeeded" or fc
or not any(criteria_met.values()) or not evidence): state = "FailedUnverifiable"
else: state = "Succeeded"
return {"schema": "loop-engineer/terminal@1", "state": state,
"criteria_met": {k: v is True for k, v in criteria_met.items()},
"evidence": list(evidence), "false_completion": fc}
```

## Gate it in CI

```yaml
- run: pip install loop-engineer
- run: loop doctor run/ # -> {"ok": true}: the contract is structurally honest
- run: loop metrics run/ # -> false_completion_rate + evidence-backed scorecard
```

`loop metrics` scores the run from its on-disk evidence — not from the swarm's
narration, its consensus rows, or its `truthScore`.

Verified against `ruflo` 3.32.9 (2026-07-25). ruflo moves fast (27 minor versions
in three months), so pin the version you supervise.

Full runnable example (happy path + `--sabotage-holdout` false-completion demo +
interrupt and spec-gap demos, replaying a committed recording so it runs offline):
[`examples/ruflo-gate/`](../../examples/ruflo-gate/).
67 changes: 53 additions & 14 deletions docs/superpowers/specs/2026-06-30-st3-integration-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,30 +289,69 @@ peeked" case OpenHands can't itself catch.

### 5.4 ruflo swarm → acceptance gate *(alternate)*

> **Corrected 2026-07-25.** The original §5.4 sketched `ruflo.orchestrate()`
> returning `.visible` / `.holdout` / `.merged_diff` / `.agent_trails` /
> `.converged` / `.rounds` / `.max_rounds` / `.criteria_met`. **No such API
> exists** — ruflo is a Node CLI with no Python package, no `orchestrate()` entry
> point and no result object with those fields (verified against `ruvnet/ruflo`
> at 3.32.9). The original text also assumed a swarm-terminal hook to register
> the gate on; the plugin `HookEvent` enum has no swarm-level terminal event
> (`swarm:consensus-reached` occurs only in ruflo's documentation, never in its
> implementation). Both are replaced below by the verified surfaces. Research
> dossier: `review/recipes/2026-07-25-ruflo-api-research.md`.

**Composes:** the ORCHESTRATE tier (multi-agent swarm). A swarm's terminal is
"the coordinator decided the objective is met" — pure self-report across N agents.
Loop Engineer adds a single acceptance gate the swarm must pass *as a whole*.

Snippet outline: register the gate as the swarm's terminal hook (ruflo exposes
hooks / an MCP coordination server), so no individual agent can declare the swarm
done — the acceptance gate does:
Seam: `ruflo hive-mind spawn "<objective>" --claude` spawns the Claude Code CLI as
the swarm's execution body and **blocks** until that child exits, mapping `exit 0`
to success. So the gate belongs in the **host-side supervisor** that launches the
CLI and reads the run directory afterwards — no individual agent can declare the
swarm done, because nothing inside the swarm writes the terminal. Optionally a
second, independent gate runs inside the child via `hooks/stop_firewall.py`
registered as a Claude Code `Stop` hook.

```python
swarm_result = ruflo.orchestrate(objective=spec, agents=[...])
gate = decide(visible=swarm_result.visible, holdout=swarm_result.holdout)
ac = anticheat_scan.scan(diff=swarm_result.merged_diff,
trajectory=swarm_result.agent_trails)
run = subprocess.run(["npx", "ruflo@3.32.9", "hive-mind", "spawn", objective,
"--claude", "--non-interactive"], cwd=ws) # blocks

obs = observe(ws) # .swarm/{state.json,tasks/*,agents/*,coordination/*} + memory export
gate = decide(visible=visible_checks(ws), holdout=holdout_checks(ws))
ac = anticheat_scan.scan(diff_text=host_git_diff(ws), # ruflo exposes no merged diff
trajectory=agent_trails(obs)) # agents' touchedPaths + consensus rows
terminal = to_terminal_state(
outcome=EngineOutcome(reached_end=swarm_result.converged, external_error=None,
budget_exhausted=swarm_result.rounds >= swarm_result.max_rounds,
human_abort=False, artifacts=swarm_result.artifacts),
gate_verdict=gate, anticheat=ac, criteria_met=swarm_result.criteria_met,
outcome=EngineOutcome(
reached_end=run.returncode == 0
and obs["state"]["status"] in {"ready", "initialized", "stopped"},
external_error=None if run.returncode == 0 or completed_tasks(obs) else "swarm blocked",
budget_exhausted=autopilot_capped(ws), # `ruflo autopilot status --json`
human_abort=supervisor_was_interrupted(), # NEVER from the exit code — see below
artifacts=evidence_paths(ws)),
gate_verdict=gate, anticheat=ac,
criteria_met={cid: proven.get(cid) for cid in declared_criteria(obs["export"])},
)
```

Mapping specialization: swarm non-convergence within max rounds → `FailedBudget`;
a criterion no agent was assigned → `FailedSpecGap` (the swarm literally never
worked on it — a failure mode a self-reporting coordinator hides).
Criteria vocabulary comes from the memory export's `sparc-phases` `spec-*` entry
(`acceptanceCriteria`); their **truth** comes from the withheld holdout checks.
The `sparc-gates` namespace holds the swarm's own per-phase `pass` rows and
`truthScore` — recorded as observation, never trusted as a verdict.

Mapping specialization: autopilot `--max-iterations` / `--timeout` reached without
a green gate → `FailedBudget`; a declared criterion no check covers →
`FailedSpecGap` (the swarm literally never worked on it — a failure mode a
self-reporting coordinator hides).

Two traps the recipe must encode: ruflo's SIGINT path calls `process.exit(0)`, so
`AbortedByHuman` must come from the supervisor's own signal handler and never from
the exit code; and `ruflo verify` is **install-integrity** (SHA-256 + Ed25519 over
the installed artifact), not a run verdict, so it must never be wired as the gate.

Because a live run needs Node, the `claude` binary and credentials, the shipped
example replays a committed `.swarm/` recording by default (`--live` opts in) —
stated plainly, with the gate/projection/emit/doctor/metrics path executing for
real.

---

Expand Down
82 changes: 82 additions & 0 deletions examples/ruflo-gate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# ruflo recipe — swarm below, acceptance gate above

A runnable **host-side supervisor** around [ruflo](https://github.com/ruvnet/ruflo)'s
blocking swarm CLI. ruflo keeps its own orchestration substrate (Queen coordinator,
worker agents, SPARC phases, `.swarm/` + `.hive-mind/` state); Loop Engineer adds
the contract/proof tier above it — evidence-backed state the `loop` CLI can
independently validate and score.

## Why a supervisor and not a hook

`ruflo hive-mind spawn "<objective>" --claude` spawns the Claude Code CLI as the
swarm's body, **blocks** until that child exits, and maps `exit 0` to success.
There is no swarm-terminal callback to register: the `hooks` subcommands are calls
*into* ruflo, and the plugin `HookEvent` enum has no swarm-level terminal event
(`swarm:consensus-reached` appears in ruflo's docs but not in its implementation).
So the gate lives in the process that launches the CLI — plus, optionally, a
second independent gate inside the child via this repo's Claude Code `Stop`-hook
firewall (`hooks/stop_firewall.py`).

## What it shows

`swarm_example.py` supervises one run:

1. **drive** the swarm (replayed by default — see below),
2. **observe** only host-side surfaces: `.swarm/state.json`, `.swarm/tasks/*.json`,
`.swarm/agents/*.json`, `.swarm/coordination/*.json`, and the
`ruflo memory export` JSON,
3. **gate** it with the withheld holdout split (`holdout_gate.decide`) plus the
trajectory sweep (`anticheat_scan.scan`) over the agent trails,
4. **project** through `to_terminal_state` and **record** via `loop.emit`, which
refuses a dishonest `Succeeded` before anything hits disk.

The swarm's own `sparc-phases` `acceptanceCriteria` supply the criteria
*vocabulary* (`AC-1`…`AC-3`); their truth comes from the withheld checks. The
swarm's `sparc-gates` self-verdict (all phases `pass`, `truthScore: 0.97`) is
recorded as an observation in `swarm-observation.json` and never used to decide.

## Fixture replay is the default — and it is stated, not hidden

A live ruflo run needs Node, `npx ruflo`, the `claude` binary, credentials and
real model spend, so the shipped default **replays the committed recording in
`fixture/`** — a `.swarm/` tree in ruflo's layout plus the work product the
recorded run left behind. `--live` opts into the real invocation.

Only the swarm is recorded. The gate, the projection, `loop.emit`, `loop doctor`
and `loop metrics` all execute for real against the replayed workspace. A recipe
that quietly faked the engine *and* the gate would be exactly the false
completion this project exists to catch.

## Run it

```bash
pip install loop-engineer
python swarm_example.py demo-run/ # replay: Succeeded, offline
loop doctor demo-run/ # -> {"ok": true, ...}
loop metrics demo-run/ # -> clean scorecard (FCR 0.0)
```

### Demos

| Flag | What it proves |
|---|---|
| `--sabotage-holdout` | the work product still claims 41 unique rows (visible green) but the dropped-row log is truncated (holdout red) → `FailedUnverifiable`, `false_completion: true`, **never** `Succeeded` |
| `--simulate-interrupt` | ruflo exits **0** on Ctrl-C, and the gate is green — yet the supervisor's own interrupt flag yields `AbortedByHuman` |
| `--declare-unmapped-criterion` | the swarm declares `AC-4` that no check covers → `FailedSpecGap`, the failure a self-reporting coordinator hides |
| `--live` | really runs `npx ruflo@3.32.9 hive-mind spawn … --claude` (Node + `claude` + credentials + spend) |

In live mode the supervisor installs a `SIGINT` handler *before* spawning, because
ruflo's own SIGINT path calls `process.exit(0)` — `human_abort` is never inferred
from the exit code.

The gate tools (`holdout_gate`, `anticheat_scan`) resolve from `loop._resources`,
so a plain `pip install` is enough; a repo checkout picks them up from `scripts/`.

## The general pattern

The complement framing, the full host-side signal table, the three traps, and the
copy-paste (zero-install) projection live in
[`docs/integrations/ruflo.md`](../../docs/integrations/ruflo.md).

Verified against `ruflo` 3.32.9 (2026-07-25). Fixture prose is original; only
directory names and JSON key names follow ruflo's conventions.
20 changes: 20 additions & 0 deletions examples/ruflo-gate/fixture/.claude-flow/metrics/v3-progress.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"version": "3.32.9",
"initialized": "2026-07-25T09:14:02Z",
"domains": {
"completed": 1,
"total": 1,
"status": "COMPLETE"
},
"swarm": {
"activeAgents": 0,
"maxAgents": 8,
"topology": "hierarchical-mesh"
},
"learning": {
"status": "READY",
"patternsLearned": 2,
"sessionsCompleted": 1
},
"_note": "Snapshot after the swarm drained back to idle. Progress, not proof."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Queen coordinator briefing — swarm-2026-07-25-csv-dedupe

Objective: collapse duplicate contacts during the CSV import so that rows sharing
a normalized (email, phone) key are written once, the first-seen row is retained,
and every dropped row is recorded with its source line number.

Coordination notes for the worker agents:
- Spawn one coder per acceptance criterion; the reviewer re-runs the import.
- Record each SPARC phase artifact into the sparc-phases memory namespace.
- Verify all subtasks are complete before reporting the objective as met.

This prompt file is the record of what the swarm was asked to do. It is an input,
not evidence of the outcome — the outcome is decided by the supervising gate.
12 changes: 12 additions & 0 deletions examples/ruflo-gate/fixture/.swarm/agents/agent-coder-02.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "agent-coder-02",
"swarmId": "swarm-2026-07-25-csv-dedupe",
"role": "coder",
"status": "completed",
"spawnedAt": "2026-07-25T09:16:58Z",
"tasksCompleted": 1,
"touchedPaths": [
"src/import_contacts.py",
"dedupe-report.json"
]
}
12 changes: 12 additions & 0 deletions examples/ruflo-gate/fixture/.swarm/agents/agent-coder-03.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "agent-coder-03",
"swarmId": "swarm-2026-07-25-csv-dedupe",
"role": "coder",
"status": "completed",
"spawnedAt": "2026-07-25T09:17:09Z",
"tasksCompleted": 1,
"touchedPaths": [
"src/import_contacts.py",
"dedupe.log"
]
}
Loading
Loading