closedloop
v0.2.1
Published
Composable, fail-closed agent workflow blocks for TypeScript.
Readme
closedloop
closedloop is a fail-closed agent workflow package for TypeScript, with a JSON CLI built from the same composable blocks.
The completion invariant is strict:
Loop termination is determined by files and command measurements, never by an agent's stdout.
An agent may print any claim it wants. Conditions cannot read agent stdout, stderr, or RunResult, so those claims cannot authorize completion.
Prerequisites
- Node.js 20 or newer.
- Linux or macOS.
- The Git working tree must be clean before a run. Commit or stash first. The CLI refusal is:
git working tree is dirty; commit or stash changes first. - Codex, or another agent CLI, must be installed and authenticated separately.
closedloopnever handles agent credentials.
On WSL, run which npm and confirm it resolves under /usr/, not /mnt/c/. Installing with Windows npm creates a non-executable shim that fails with Permission denied; reinstall Node and npm inside Linux.
Install
npm install closedloopJSON CLI
JSON remains the primary path for the built-in repository worker/reviewer preset.
{
"repo": "/absolute/path/to/repository",
"defaults": {
"worker": {
"command": "codex",
"args": ["exec", "--sandbox", "workspace-write", "{{prompt}}"]
},
"reviewer": {
"command": "codex",
"args": ["exec", "--sandbox", "workspace-write", "{{prompt}}"]
},
"checks": ["npm test"],
"maxAttempts": 3,
"timeoutSeconds": 900,
"isolate": true,
"verbose": false,
"context": ["docs/spec.md"],
"constraints": ["Do not change the public response schema"]
},
"jobs": [
{ "name": "implementation", "task": "Implement the requested feature." }
]
}Run it:
closedloop closedloop.json
closedloop --json closedloop.json
closedloop --keep closedloop.json
closedloop --quiet closedloop.jsondefaults are merged into every job before validation; job keys override defaults. A v0.1.0 flat config is still accepted and becomes one job named default.
verbose controls how much detail is stored in the run log; it does not control terminal output.
Every resolved job must have at least one objective decider: a reviewer writing verdict.json, or one or more deterministic checks. Jobs run sequentially and stop at the first non-passing outcome. Accepted repository content accumulates, while every job receives a fresh measured baseline. isolate remains a job-level setting.
Only an argv element exactly equal to {{prompt}} is substituted. Missing placeholders, duplicate placeholders, and substring use are rejected. Commands are spawned directly with shell: false.
Library API
The four blocks are agent, run, seq, and loop:
import { agent, run, seq, loop, verdictPassed } from "closedloop";
const worker = agent("worker", {
command: "codex",
args: ["exec", "--sandbox", "workspace-write", "{{prompt}}"],
});
const reviewer = agent("reviewer", {
command: "codex",
args: ["exec", "{{prompt}}"],
});
export default loop({
body: seq([run(worker), run(reviewer)]),
until: verdictPassed(),
max: 3,
});The repository copy of this example is examples/review-loop.ts.
Run that repository example directly against Codex:
npx tsx examples/review-loop.ts "Implement the requested change and verify it."Execute a workflow with task text supplied at invocation time:
import { execute } from "closedloop";
import workflow from "./review-loop.js";
const result = await execute(workflow, {
task: "Implement the cache invalidation feature.",
worktree: process.cwd(),
});
if (!result.ok) process.exitCode = 1;The task lives at ctx.task; it is never baked into a reusable workflow.
The four blocks
agent(name, { command, args, context? })declares a reusable command-backed agent.contextis static prompt material, not a retained provider transcript.run(agentDef, options?)invokes one agent. A custom prompt can read the currentRunContext, for examplerun(reviewer, { prompt: (ctx) =>Review this work.\n\n${ctx.task}}).seq(blocks)executes blocks in order and stops at the first failure.loop({ body, until, max })repeats a block until measured state passes or the required positive integermaxis reached.
Blocks implement:
interface Block {
readonly kind: string;
execute(ctx: RunContext): Promise<BlockResult>;
}Blocks nest freely. A loop body may be a sequence, and a sequence may contain another bounded loop.
Step input and output
Every run() receives protected containers:
.git/closedloop/runs/<runId>/steps/<n>/in/
.git/closedloop/runs/<runId>/steps/<n>/out/The generated prompt supplies the absolute paths and requires the agent to read from in/ and write artifacts to out/. Repository edits still happen in the shared working tree.
By default, the previous step's entire out/ tree is copied into the next step's in/. Explicit wiring replaces that default:
run(reviewer, {
inputs: { "spec.md": "steps/2/out/design.md" },
});Step directories are mode 0700; copied and produced files are normalized to 0600. Symlinks and other special output entries are rejected. Agents receive private temporary in/ and out/ mirrors so sandboxed providers never write directly into Git metadata; validated outputs are copied into the canonical run directory before conditions execute.
For codex exec agents, ClosedLoop supplies the dynamic step output directory through --add-dir. If no Codex sandbox option was specified, it selects workspace-write; an explicit sandbox choice is preserved.
Conditions
Conditions receive only a StateView:
interface StateView {
worktree: string;
readFile(path: string): Promise<string | null>;
exists(path: string): Promise<boolean>;
runCommand(cmd: Command): Promise<{ exitCode: number | null; output: string }>;
stepOut(stepIndex: number, path: string): Promise<string | null>;
}Built-ins:
verdictPassed(file = "verdict.json")reads the latest step output and passes only for an exact lowercase"pass"verdict.checksPass(commands)runs direct commands and passes only when every command exits0.fileExists(path)measures a repository-relative file.
StateView deliberately does not expose agent stdout, stderr, step command results, or the complete Run result. Command output obtained through runCommand is a measurement; an agent's own output is only a claim.
Reviewer verdict file
A reviewer must write this file into its step out/ directory:
{ "verdict": "pass", "feedback": "optional explanation" }or:
{ "verdict": "fail", "feedback": "what must change" }The following all fail closed:
- missing
verdict.json - malformed JSON or malformed fields
- any verdict other than exactly
"pass" - reviewer timeout, crash, or nonzero exit
- reviewer mutation of the repository
- missing, incomplete, ambiguous, or oversized evidence
Failure feedback from the verdict file and failing check output is appended to the next attempt's prompt. Reviewer stdout is retained only as diagnostic evidence and never parsed for completion.
See MIGRATING.md for the v0.1.0 breaking change.
Events and inspection
Events are appended to:
.git/closedloop/runs/<runId>/events.ndjsonEach line is written and fsynced while execution is active. v0.1.0 event shapes remain, with two additions:
block_started:kind,name, andstepIndexblock_finished:kind,name,stepIndex, andok
Inspect without blocking:
closedloop runs
closedloop status [runId]
closedloop status [runId] --json
closedloop watch [runId]
closedloop watch [runId] --jsonwatch --json forwards the stored NDJSON exactly. Non-verbose jobs omit full prompt, output, feedback, and diff bodies.
Watching a run
Runs print live progress to stderr by default while keeping stdout unchanged:
run ms1example started · follow with: closedloop watch ms1example
[1] worker_started
[1] worker_exited exit=0 31.2s
[1] check_result npm test exit=0 12.0s
[1] reviewer_exited exit=0 18.1s
[1] verdict fail
[2] worker_started
run finished: passed (2 attempts, 94.8s)Follow the stored event stream or inspect current state from another terminal:
closedloop watch <runId>
closedloop status <runId>Use --quiet on the run command to suppress live progress and the watch hint. runs, status, and watch are unaffected.
Observable states are running, finished, and crashed. Terminal outcomes and exit codes remain:
| Outcome | Exit |
|---|---:|
| passed | 0 |
| exhausted | 2 |
| limitCycle | 3 |
| aborted | 4 |
| invalid configuration/usage | 5 |
Isolation and safety
Isolated jobs run in temporary Git worktrees. Failed worktrees are removed unless --keep is supplied; passing worktrees are retained for inspection and merge. Cleanup uncertainty aborts rather than claiming success.
Run directories are 0700; events and reports are 0600. Context files are repository-relative, loaded before execution, and rejected above 60 KiB rather than silently truncated. Runtime dependency: Zod only.
Troubleshooting
Upstream agent CLIs may print their own warnings, such as Codex model-cache messages. Those messages are not produced by closedloop and are usually non-fatal; use the recorded exit code and run outcome to determine whether the command failed.
Non-goals for v0.2
Not included: parallel blocks, branching, backtrack routing, persistent agent transcripts, multiple reviewers, crash resume, cost tracking, or a workflow-file loader.
Conditions cannot read agent stdout by design: a model's statement about completion is not a measurement of repository or artifact state.
