Keep it running
Watchers only run while scf ui (or scf serve) runs. When a new version of
Spaghetti Code Foundry is built (npm run build), the running server restarts itself as soon as no run
is active — no need to stop and start it. To keep the Foundry running in the background on macOS,
also after a restart of your Mac:
scf service install # uninstall | status
If you installed the service before, run scf service install once. It replaces the old
com.claude-factory.server agent with com.spaghetti-code-foundry.server, and puts the old one
back if the new one can't start.
The model analyst#
A separate process that reads your own runs every 6 hours and says, per step, how the model does and whether another level fits the work. It reads run files and writes a report, and it does not need the server. By default it only advises; with self-tuning on it changes the model or effort of a step by itself (see below).
scf analyst install # run every 6 hours as its own macOS login agent; the first time now
scf analyst run # write a report now (--no-web: do not look up published figures)
scf analyst report # print the last report
scf analyst status # uninstall removes the agent
scf analyst auto on # let it change models and efforts by itself; auto off: only advise
scf analyst reset # put back everything it changed
The report is analyst/report.md in the data folder; the same advice as data is in
analyst/latest.json. It looks at the last 14 days and needs 20 visits of a step before it says
anything; visits stopped by a usage limit do not count towards the 20. Per step and model it shows how often the step worked, ran into its time limit, was
repaired by a fix_* step, or was sent back by a review, and its share of all tokens. From that:
- Try a lower level. The step works nearly always and its work is rarely repaired. The advice is one effort level down when the step runs on high or above, otherwise the next smaller model. For a review: almost none of the reviews led to a change, and the review step itself works at least 97% of the time.
- Try a higher level. The step fails or its work is repaired often. A review step that fails 15% of the time or more is treated the same way: it is never advised to go lower.
- Time limit, not model. 5% or more of the visits ran into the time limit. A stronger model is usually slower, so look at the limit and the size of the work first.
- Look at this. The step got clearly worse in the last two days.
Visits stopped by a usage limit are not counted against a model. The advice rests on what happened, not on a trial of the other model. Self-tuning does that trial.
Self-tuning (scf analyst auto on; off by default). At every check the analyst may start a
trial: it takes the advice with the largest token share that says "effort …" or names a Claude
model, and sets it for that step. There is one trial per flow at a time, so the effect of a change
can be told apart. The change is written to analyst/overrides.json; the engine reads that file at
the start of every agent step, so it takes effect at the next step, also in a run that is under way.
No flow file is changed.
Some advice stays advice, and the report says why under "Advice not tried by itself":
- Another model is only tried when the published figures (see below) hold at least one figure
for that model. The analyst makes the name of the next level from the name of the current one, and
this is its check that such a model exists. With
--no-web, or before the first lookup worked, no model is tried. Effort changes need no figure. - A review none of whose reviews led to a change. A trial of it could not be judged.
- A step inside a sub-flow.
- A change back to what the flow itself says.
scf analyst resetdoes that.
What the analyst set never wins over you:
- A router rule of yours for the step wins, for the model and for the effort.
- The override follows the flow. It stores what the flow said for the step (model and effort) when the analyst set it, and applies only while the flow still says that. When you edit the step's model or effort, your edit counts from the next step on.
- Only the model is replaced. The step keeps its agent and its provider, so a step on a local model or on a proxy stays there. The model of a step that does not run on Claude Code is never changed.
A trial is judged on the visits since it started, against the numbers of the step before it:
- After 20 visits a lower level is kept unless the step does worse: it works more than 5 points less often, its work is repaired more than 10 points more often, it times out more than 5 points more often, or reviews send its work back more than 15 points more often. For a review step: unless it leads to a change less than half as often as before.
- A higher level is kept only when the step works at least 5 points more often or is repaired at least 10 points less often. Otherwise it is not worth its tokens and is put back.
- After 8 visits a trial that works 25 points less often is stopped at once.
- A trial with fewer than 20 visits in 7 days is put back.
A change that was put back is not tried again for 30 days, and a step is left alone for 3 days
after any trial. The run log of a step says "set by the analyst" when an override applied. The
report lists what is set now, what happened at this check, and the last ten trials;
analyst/tuning.json holds the full record, and it owns the overrides: an entry in
overrides.json that no running or kept trial stands for is removed at the next check, so do not
add entries by hand. scf analyst auto off stops new trials and keeps what is set.
scf analyst reset removes it all and ends the running and the kept trials as "put back", so the
same changes are not tried again for 30 days.
Published figures. Once a week the analyst asks Claude Code (with the Claude sign-in of this
machine) for published scores, prices and speeds of the models you use and the
levels next to them, and keeps them in analyst/reference.json with the source of each figure.
Figures a third party measured itself are used as they are. Figures from the makers, and figures
whose origin is not given, are lowered by a tenth. They are only used to hold back advice for a
smaller model that scores far lower, and to know that a model exists before it is tried; your own
runs always decide. Coding scores are only compared when both name the same test. That session has
the two web tools (search and fetch) and no others, no MCP servers, none of your personal or
project settings, and runs in an empty temporary folder. When the search fails or finds nothing,
the earlier figures are kept and it is tried again a day later. On Linux, run scf analyst run
from cron.
The command line for a running Foundry#
Everything the web interface does goes through the server's API, and every call of that API is
also a command. These commands talk to the running Foundry, with your account and its rights. (The
older commands such as scf run, scf resume and scf user work on the files of the data folder
and need no server; they are unchanged.)
scf login # asks for your e-mail address and password; --server names another Foundry
scf whoami # who is signed in, and where
scf runs list --status failed
scf turn list # what waits for you
scf repos # an area without an action lists its commands
scf flows --help # flows, blocks and monitor: without an action they are the older file commands
scf runs list --help # the options of one command
scf logout
Signing in. scf login uses the same accounts as the web interface. The password is typed
without echo, sent to the server and not kept. What is kept is the session, in
cli-session.json in the data folder, readable by your user only. It ends after 7 days, at
scf logout, or when the account's password changes. A script can pipe the password in as one
line; without --email it pipes two lines, the e-mail address and then the password. Only a
Foundry on this machine (localhost, 127.0.0.1, [::1]) is reached over plain http. Any other
address is reached over HTTPS: --server foundry.example.com means https://, and an http://
address of another machine is refused, so the password never travels in clear text. A redirect is
not followed; the command says where the server points to and stops.
The areas.
| Area | What it covers |
|---|---|
runs | List, show, start, answer, approve, reject, resume, cancel and archive runs; a step's log and the diff |
turn | What waits for you, and answering it: answers, defaults, approve, reject, retry |
board, find | The board, the next steps, what changed since a time; search |
repos | Your repositories: add, sign-in method, test the connection, remove |
refine | Refinement sessions: story drafts, suggestions, reviews, the publish plan, publishing |
ask | Ask SCF: ask a question, try again, stop; rate an answer (feedback); your conversations (list, show, complete with --resolved, archive, restore, delete); what a page context is worth (context); the usage and quality numbers (admin) |
flows, blocks | List, show, check, save and remove flows and blocks |
settings, providers | The settings file; the AI tools and model providers |
monitor | The self-monitor: findings, mutes, bug stories |
users, admin, audit | Accounts and their limits, all repositories and their watchers, the audit log (admin) |
account, secrets | Your own password; your stored secrets |
watchers, server | Watchers; server information, queue, statistics, clean-up |
What every command has.
--jsonprints the server's answer as it is, for scripts. Without it you get a table or a few lines.--data '{…}'or--data @file.jsonadds fields to the call that have no option of their own. It only adds: a field that an option, an argument or the command itself sets is refused, and so is a field that another command asks about first (the role inscf users set, the sign-in method inscf repos add). A command that only reads takes no--data.- Text that starts with
-goes after--:scf flows save my-flow --scope repo -- "$(cat my.yaml)". --server <address>talks to another Foundry (sign in there first).--as <account id>reads as that user would, for an admin who startedscf admin view-as.- Exit code 0 means done; 4 means not signed in or not allowed (also for
scf whoami); 1 is any other refusal by the server. Errors are printed on standard error; with--jsonthe server's whole refusal is printed there as JSON. - A call may take two minutes (a sign-in half a minute); then the command gives up. An answer that is not from a Foundry counts as an error, whatever its status.
Safety.
- A command that deletes or replaces something asks you to type
yes.--yesanswers for you; without a terminal and without--yesit stops.scf flows saveandscf blocks saveask only when a file with that name is there already;scf users addasks only for--role admin. - A password, token or other secret is never an option or an argument: the command asks for it
without echo (or reads one line of standard input each, in the order it would ask), so it is not
in your shell history. Given inline in
--datait is refused, for every command (the fieldstoken,password,secret,current,key,privateKey); in a file (--data @file) it is accepted, unless the field is one the command does not let--dataset. - An argument that becomes part of the address (an id, a name) cannot be
.or..and cannot contain/or\. scf settings setsets all settings: a setting you leave out goes back to its default, so it insists on--data. The Slack webhook is the exception: it stays unless you givenotify.slack_webhook: null, which removes it. Start fromscf settings show --json; it does not show the webhook, onlynotify.slack_webhook_set.
Any call directly. scf api routes [word] lists every call the server knows and who may make
it; scf api GET runs (or POST, PUT, PATCH, DELETE) makes one. A body is sent only with
--data: without it nothing is sent, so add --data '{}' for a call that wants an empty one.
DELETE, PUT and PATCH ask you to type yes first (--yes answers; without a terminal and
without --yes they stop). One call has no command: the live event stream of a run, which never
ends; scf api refuses it too.
Helper processes#
Five small processes that run beside the Foundry, each started by macOS by itself once installed.
They read the data folder and write their results in their own folder there. Two of them also set
something outside it: the governor and the limits helper each keep their cap on the runs at once in
a file of their own in governor/caps/, and the governor writes test-slots/cap. None needs the
server, and the server does not need them. Every one has the same commands:
scf governor install # also: flaky, limits, blockage, digest, housekeeping
scf governor run # one check now
scf governor report # the last result
scf governor status # uninstall removes it and what it set
status says "installed" only when macOS really runs the helper. "Installed but not loaded" means
the file is there and nothing runs: run install again.
| Helper | Runs | What it does |
|---|---|---|
governor | every minute | Lowers the number of runs at once while the machine is overloaded, and raises it again when it is calm. |
flaky | every 6 hours | Finds tests that fail in unrelated stories or pass only on a retry. |
limits | every hour | Says when the weekly Claude and Codex limits are reached at the current pace. |
blockage | every hour | Finds the stories that hold up the most others, and says when run slots are empty. |
digest | daily at 07:30 | One short report: what was done, what failed, what waits for you. |
housekeeping | every 3 hours | Records the memory of the system's file-change tracker and of the Foundry server, and once a night removes the workspaces of old runs. |
Caps on the runs at once. The governor and the limits helper can each lower the number of runs
at once for a while. The lowest cap that still holds counts, never below 1 and never above the
configured number. A cap holds back new runs only: work that runs is never stopped, and a run that
already started and is resumed (after a restart, an answer or a pause) is not held back, because it
may hold the turn to merge that others wait for. A new run that waits only for a cap is marked so in
the queue (capped, and the queue names the cap), and the monitor does not report it as a queue
that stands still.
The load governor. It reads the load average per core and the memory pressure. Above 2.5 load
per core, or under critical memory pressure, it allows one run fewer per check, down to half the
configured number (at least 2). Memory pressure "warning" counts only together with a load above 1.5
per core: macOS reports it for hours on a full but idle machine. After five calm checks in a row
(1.5 per core or less) it allows one more, until the configured number is back. In between (a load
between 1.5 and 2.5, or a memory warning without load) nothing moves at first, but after 15 checks
in a row without overload it allows one more as well, so a cap does not stay for hours on a machine
that is only a little busy. Above 4 per core, or under critical memory pressure, only one test run
works at a time. Every change is a line in governor/changes.log. Its limits end by themselves
four minutes after the last check — the cap on runs, the cap on test runs, and what the governor
remembers — so a governor that stops can never hold the Foundry down, and the first check after the
machine slept starts fresh. scf governor reset takes them away at once. The daily release check
asks for a test slot like every test run, but waits five minutes at most.
Unreliable tests. A test is listed when it failed, or passed only on a retry, in at least three different stories of one repository in the last 7 days: one story can break its own test, several cannot. A run without an issue is not a story. Where it can see them:
- A test that failed in a test log (Vitest and Gradle output).
- A test that failed and passed when the flow ran the tests once more: the flows keep the first log
as
tests.first.logfor that. - A test that Vitest retried by itself — only when the test command uses
--reporter=verbose. Vitest's default reporter prints nothing for a test that passed on a retry.
The report names a likely cause when the error of that test shows one (a port in use, a time
limit, a full disk), and says whether the test was also seen to pass. With scf flaky auto on it
makes one bug story per test file and repository through the GitHub sign-in of this machine, at
most 2 per check and never twice for the same file. A story is made only when at least one listed
test of the file was also seen to pass in those 7 days (on a retry, on the second run, or named as
passed in a verbose log); a test that never passes is broken, not unreliable, and stays in the
report only. Off by default.
Usage limits. The weekly limits are not published, so the forecast needs one reading from you:
scf limits set claude 34 2026-10-19T23:00 # 34% used now; the week resets then (local time)
A reset without a time zone is local time, also a date alone (midnight). A reset more than 7 days
ahead is refused. From the tokens used since the reset and that share it works out the size of the
week, and from the pace of the last 24 hours when the limit is reached. It is an estimate: it holds
while the mix of work stays about the same, and a reading below 5% is too coarse. With
scf limits auto on it lowers the runs at once so that the limit lasts until the reset — down to 1
if needed, lower than the governor goes. It works from the number of runs that are allowed at that
moment, lowers its cap as far as needed in one check, and raises it by one per check at most. The
cap ends by itself two hours after the last check. Off by default.
What holds work up. It follows two kinds of waiting: a story that stepped aside for files another run holds, and a story that waits for one it depends on (from the service log of the last 6 hours, until the log shows that the story started or ended). The report lists the stories at the root, the number they hold up through the chain, and their state; stories are named with their repository. Stories that wait for each other in a circle are listed first, as one entry: that never ends by itself. A run that says "running" while its process is gone is shown as interrupted. The report says so when run slots are empty while stories wait.
Housekeeping. Every run installs and deletes very many small files, and macOS keeps track of
every change. On a machine that runs for days, the system's file-change tracker (fseventsd) once
grew to tens of gigabytes this way and left too little memory for the work. So every 3 hours this
helper writes one line to housekeeping/memory.log with the memory of fseventsd and of the
Foundry server. Above 8 GB for the tracker or 6 GB for the server it sends one notification that
day, and the digest names it. The tracker only shrinks with a restart of the machine. Once a night,
at the first check after 03:00, it removes the workspaces of runs that ended more than 2 days ago,
as scf clean --older-than 2 does: run records, logs and branches stay, and active or paused
runs are not touched.
The digest. For the last 24 hours, from the newest run of each story: done, split into smaller
stories, failed (with the reason) and in progress; what waits for you is listed however old it is,
and so are runs that were interrupted (they say "running", but their process is gone). Below that
come a few lines from the other helpers and the analyst; a result of a helper that is older than
26 hours is left out. It is digest/report.md, and one file per day, named by the local date; a
desktop notification gives the counts.
Self-update#
Off by default, because it lets merged code run on this machine without a person looking. For a Foundry that runs from a git checkout of its own repository. Switch it on in Settings → Safety and name the repository (self_update: { enabled: true, repo: owner/name } in config.yaml). After upgrading, stop and start the Foundry once, so that it can go back after a bad update.
Every 5 minutes it checks, in this order, and stops at the first reason that applies (the Health line shows it):
- The record
self-update.jsoncan be read, and no earlier go-back failed. - The Foundry runs from a git checkout, under the supervisor (
scf ui/scf serve, or the service). - The
originof the checkout is the repository in the setting. maincan be read from GitHub. Nothing new: nothing to do.- The checkout is on
mainand has no local changes (untracked files count). Only then does it fetch. - The same commit did not fail after its last try. It waits for a newer commit. (A failed test run is tried again 20 minutes later, up to 3 tries in all; the wait is shown in the Health line.)
- The checkout can fast-forward to
main.
Then it builds and tests the new commit in a separate folder (npm ci, npm run build, npm test). The tests take a test slot like the tests of stories: 2 run at once, and they wait at most 15 minutes for a free one, then run anyway; their own 15-minute limit starts when they start, and a run that hits it counts as a failed try. If the tests fail, they run again after 20 minutes, up to 3 tries in all, and the Health line says which try it is. The log and the Health line after the third failure name the failing tests (at most 20 are kept). If the install or the build fails, it stops after one try. Until the last try ends, the old version keeps running; a failure after the last try gets a monitor finding. When all pass, no new runs start (queued runs wait and start after the restart) and the server restarts when the active runs are done. Right before the install it checks everything again, including the tip of main. It never updates from a branch other than main or from another repository.
Skipped tests. When the daily release check (release-daily) already passed the same tests on the same code, self-update does not run them again. The log then says self-update: <sha> is not tested again: the release check passed these tests on this code (run <id>, <time>); install and build still run. It skips only when all of these hold: the test command is the updater's own test step (npm test, or npm ci && npm test when node_modules was missing), the release check recorded a green run of that command on the same tree in the data folder (tested/), and the record has exactly the form the check writes. Every other case runs the tests as usual: a story's record, another tree or command, no detected command, an unreadable record, or a test_cmd pinned in .claude-factory/config.yaml of the code, even when a record matches. A release check with its own test_cmd records under that command, so it matches only when that command is the updater's own test step. Only the release check's record counts. Anything that can write tested/ in the data folder can make self-update skip its tests, so keep that folder as private as the rest of the Foundry.
If the new server stops or does not answer within 3 minutes, the supervisor puts the previous version back once and reports it (the monitor finding is critical). An install that was cut off is finished or undone at the next start. If you switch the setting off while an update waits, the server restarts on the unchanged version when the runs are done.
When the Health line says "Self-update is stopped", the checkout needs a person: run git status, npm ci, npm run build, then delete self-update.json in the data folder. Going back does not undo changes the bad version made in the data folder.