Foreman

Foreman Code

Foreman Code: the loop, on any model

In progress. fm code is Foreman's own harness: it runs plan, edit, verify and close itself, and the model only fills one small checked slot at a time. That is what lets a small local model do real work, and a big one go faster.

Your requestone line is enough Plan slottype, size, checks, steps Action slotread, search, edit, run Foreman's guardrefusals go back to the model Harness verifiesruns the checks itself Task closedevidence, audit, commit

Open the app

fm code on its own opens the app, the way claude does. Inside a project it shows that project's active task, queue, inbox, blocked tasks and past runs; Esc lists every Foreman project on the machine with what each is doing. Enter on a task runs it in its own folder (fm code --task ID: the task's criteria and steps are the plan), n starts a new request there and m picks the runner.

Watch it work

fm code tui runs a request in a terminal UI drawn from the harness's own events: the stations as a track, each check as a pass or fail chip, changed files as bars, where the time went, and tokens per model call. A guard refusal stands out in the activity log; the model's own words are one key away (t). d shows the diff, h past runs, m picks the model or worker for the next request. It moves: the working station pulses, counters and bars ease to their values, a refusal flashes (motion = reduced or off stills it). Themes (night, paper, signal for colour-blind eyes, mono), layout and keys come from tui.toml, and a Python file in Foreman's home folder can add a panel, a command or an event handler.

Examples: real runs of every kind

Each card below is one real run, in a throwaway repo on this machine, with nothing set up but Claude Code's own CLI: --worker claude plans in Claude's read-only mode, then does each step, while fm code makes the task, runs every check before and after the work, and closes it with a commit only when the checks pass. A fix, a feature, a rename across files, docs, a question, a project from an empty folder and another language: the cards are drawn from each run's own events, kept in demo/examples/, and fm code tui --events replays any of them.

Fix a bug

fm code 'FIX: total() in cart.py ignores the quantity; an item with qty 3 should count three times' --worker claude

fm code tui at the end of this run: total() in cart.py multiplies price by qty

Plan FIX S · total() in cart.py multiplies price by qty

Steps

  1. Add a failing test to test_cart.py: total([{'price': 2, 'qty': 3}]) == 6
  2. Change cart.py line 3 to sum(i['price'] * i['qty'] for i in items)
  3. Run python3 -m unittest test_cart

Checks

  • PASS total() counts an item with qty 3 three timespython3 -c "from cart import total; assert total([{'price': 2, 'qty': 3}]) == 6" (failed before the work)
  • PASS the test suite passes, including a new qty > 1 testpython3 -m unittest test_cart -q && grep -q "'qty': 3" test_cart.py (failed before the work)

Changed

  • test_cart.py +3
  • cart.py +1 −1

Closed T-0001 closed; committed 1187356 (2 files). Undo: git revert --no-edit 1187356 (tokens: 66311 in, 503 out)

3 worker turns · 67k tokens · 57s · replay it: fm code tui --events demo/examples/fix.jsonl · the case study

Add a feature

fm code 'add a --json flag to todo.py that prints the tasks as a JSON list, with a test' --worker claude

fm code tui at the end of this run: Add --json flag to todo.py that prints tasks as a JSON list

Plan FEATURE S · Add --json flag to todo.py that prints tasks as a JSON list

Steps

  1. Write test_todo.py (unittest) that calls main(['--json']) with captured stdout and asserts json.loads(output) == TASKS, plus a check that main([]) still prints the numbered list; confirm it fails
  2. In todo.py add `import json` and, in main, if '--json' in argv print json.dumps(TASKS) and return, leaving the numbered output otherwise unchanged
  3. Run the test and the shell checks to confirm they pass

Checks

  • PASS Running `python todo.py --json` prints TASKS as a JSON list that parses back to the same listpython todo.py --json | python -c "import sys, json; assert json.load(sys.stdin) == ['buy milk', 'write report']" (failed before the work)
  • PASS Running `python todo.py` with no flag still prints the numbered list, and `--json` output is not numbered textpython todo.py | grep -qx '1. buy milk' && ! python todo.py --json | grep -q '^1\. ' && python todo.py --json | grep -q '^\[' (failed before the work)
  • PASS A test in test_todo.py covers the --json output and passespython -m unittest test_todo (failed before the work)

Changed

  • test_todo.py +25
  • todo.py +4

Closed T-0001 closed; committed a1bc78b (2 files). Undo: git revert --no-edit a1bc78b (tokens: 99493 in, 989 out)

3 worker turns · 100k tokens · 1m09s · replay it: fm code tui --events demo/examples/feature.jsonl · the case study

Rename across files

fm code 'rename fetch_user to load_user everywhere; nothing else changes' --worker claude

fm code tui at the end of this run: Rename fetch_user to load_user

Plan CLEAN S · Rename fetch_user to load_user

Steps

  1. Rename the definition in api.py (line 1)
  2. Update the import and call in views.py (lines 1 and 5)
  3. Update the call in jobs.py (line 5)
  4. Run the greps and the tests

Checks

  • PASS No reference to fetch_user remains in any .py file! grep -rq fetch_user --include=*.py . (failed before the work)
  • PASS load_user is defined in api.py and used in views.py and jobs.pygrep -q 'def load_user' api.py && grep -q 'import load_user' views.py && grep -q 'api.load_user' jobs.py (failed before the work)
  • PASS Existing tests still passpython -m unittest test_views

Changed

  • api.py +1 −1
  • views.py +2 −2
  • jobs.py +1 −1

Closed T-0001 closed; committed 60d5120 (3 files). Undo: git revert --no-edit 60d5120 (tokens: 99408 in, 462 out)

4 worker turns · 100k tokens · 1m13s · replay it: fm code tui --events demo/examples/rename.jsonl · the case study

Write docs

fm code 'document in README.md every environment variable config.py reads, with its default' --worker claude

fm code tui at the end of this run: Document config.py environment variables in README.md

Plan FEATURE S · Document config.py environment variables in README.md

Steps

  1. Add an 'Environment variables' section to README.md with a table of variable, default and meaning for APP_PORT (8080), APP_DEBUG (off; debug is on only when set to 1) and APP_DB (sqlite:///app.db)
  2. Run the three verify greps to confirm each variable is documented

Checks

  • PASS README.md lists APP_PORT with its default 8080grep -q 'APP_PORT' README.md && grep -q '8080' README.md (failed before the work)
  • PASS README.md lists APP_DEBUG with its default (unset, so debug is off unless the value is 1)grep -q 'APP_DEBUG' README.md (failed before the work)
  • PASS README.md lists APP_DB with its default sqlite:///app.dbgrep -q 'APP_DB' README.md && grep -q 'sqlite:///app.db' README.md (failed before the work)

Changed

  • README.md +8

Closed T-0001 closed; committed 085d709 (1 file). Undo: git revert --no-edit 085d709 (tokens: 66198 in, 550 out)

2 worker turns · 67k tokens · 42s · replay it: fm code tui --events demo/examples/docs.jsonl · the case study

Ask about the code

fm code 'what does parse_dates in report.py do with a date that has no timezone?' --worker claude

fm code tui at the end of this run:

Plan a question: answered from the files, no task, nothing changed

Answer

In report.py, parse_dates (lines 4-11) parses each row's r['when'] with datetime.fromisoformat (line 7). If the result has no tzinfo (line 8), it treats the value as UTC by calling d.replace(tzinfo=timezone.utc) (line 9). That attaches UTC without shifting the clock time. Every value, naive or aware, is then converted with astimezone(timezone.utc) (line 10) and appended to the returned list, so all outputs are UTC-aware datetimes.

134k tokens · 12s · replay it: fm code tui --events demo/examples/question.jsonl · the case study

Start from an empty folder

fm code 'make a small Python command-line tool, wc.py, that prints the line, word and character counts of a file, with tests' --worker claude

fm code tui at the end of this run: Add wc.py CLI that prints line, word and character counts of a file, with tests

Plan FEATURE S · Add wc.py CLI that prints line, word and character counts of a file, with tests

Steps

  1. Write test_wc.py (unittest) with failing tests: counts() on empty text, text with and without a trailing newline, multi-word lines, and non-ASCII characters; plus a main() test for a real file and a missing file (returns 1)
  2. Create wc.py with counts(text) returning (lines, words, chars) using text.count('\n'), len(text.split()) and len(text), read as UTF-8
  3. Add main(argv) using argparse for one file path argument that prints the counts and the filename, and prints 'wc.py: <error>' to stderr and returns 1 on OSError; guard with sys.exit(main()) under if __name__ == '__main__'
  4. Run python3 -m unittest test_wc and the CLI checks, then fix any failures

Checks

  • PASS Unit tests for the counting function and the CLI exist and passpython3 -m unittest test_wc (failed before the work)
  • PASS Running wc.py on a file prints its line, word and character counts followed by the filenameprintf 'a b\nc\n' > /tmp/wc_check.txt && python3 wc.py /tmp/wc_check.txt | grep -q '^ *2 *3 *6 /tmp/wc_check.txt$' (failed before the work)
  • PASS Running wc.py on a missing file exits with status 1 and a 'wc.py: <error>' message on stderr, with no tracebackpython3 wc.py /nonexistent/file >/dev/null 2>/tmp/wc_err.txt; [ $? -eq 1 ] && grep -q '^wc\.py: ' /tmp/wc_err.txt && ! grep -q Traceback /tmp/wc_err.txt (failed before the work)

Changed

  • test_wc.py +45
  • wc.py +24

Closed T-0001 closed; committed 9c48f19 (2 files). Undo: git revert --no-edit 9c48f19 (tokens: 66506 in, 1919 out)

4 worker turns · 68k tokens · 1m31s · replay it: fm code tui --events demo/examples/new-project.jsonl · the case study

Another language

fm code 'fix the page() bug in paginate.js: pages are 1-based but page 1 skips the first items' --worker claude

fm code tui at the end of this run: Fix 1-based page() offset in paginate.js

Plan FIX S · Fix 1-based page() offset in paginate.js

Steps

  1. Add a failing test in paginate.test.js asserting page([1, 2, 3, 4], 1, 2) deepEquals [1, 2]
  2. In paginate.js change the slice to use (n - 1) * size as the start offset: items.slice((n - 1) * size, n * size)
  3. Run npm test and confirm both the first-page and second-page tests pass

Checks

  • PASS page(items, 1, size) returns the first `size` itemsnode -e "const {page}=require('./paginate');require('assert').deepStrictEqual(page([1,2,3,4],1,2),[1,2])" (failed before the work)
  • PASS page 2 returns the next items, and the full test suite passesnpm test (failed before the work)

Changed

  • paginate.test.js +4
  • paginate.js +1 −1

Closed T-0001 closed; committed f8d6564 (2 files). Undo: git revert --no-edit f8d6564 (tokens: 66453 in, 645 out)

3 worker turns · 67k tokens · 58s · replay it: fm code tui --events demo/examples/javascript.jsonl · the case study

Any model, any CLI

  • Any OpenAI-compatible endpoint: Ollama, LM Studio, llama.cpp, vLLM, OpenRouter, Groq, OpenAI, Gemini's compatible endpoint, and Anthropic's API.
  • Other CLIs as workers. claude, codex, gemini or aider do a step; the harness judges the diff and runs the checks itself.
  • The same guard on every command and edit, through the hook other harnesses use.
  • A bench: fm code bench grades any model or CLI as a Foreman worker on five seeded tasks in throwaway repos, counting a task only when its own check passes afterwards.
Model client tests51
Endpoints it finds9
StatusBuilding

Next: Benchmarks