Skip to content

The agents

Homeostat’s agents run in your KB repo’s CI (or on one shared box with homeostat up), never on developers’ laptops. They read the git host, call a model through the runtime you choose, and write only to the KB repo, always by pull request.

homeostat daily is the one scheduled job. It reads only what is already on the git host, so developers’ laptops can be closed. For each developer it:

  1. Collects their pending lessons, which their sessions pushed to candidates/<member>, and drafts lessons from review comments on their own merged PRs (below).
  2. Scores each lesson for confidence, 0–100: its source, the role of whoever vouches for it (junior less, senior and lead more), whether it is specific, and its flags and conflicts.
  3. Decides each one: merge at 70 or above (80 for core), decline below 35 or as a duplicate, and a reviewer for anything in between. A new lesson that only refines a rule the KB already has becomes an update of that rule instead (below). Risky content, conflicts, document patches and areas outside scoring.autoMerge always go to a reviewer. A rule drafted from a review that would merge with nobody reading it is first checked by a second model against its thread (the judge).
  4. Opens one daily PR with the merged lessons, each decision explained in the body, and merges it. Its commits are marked [skip ci], since the branch is gone before a pipeline could start; the merge commit on the base branch is not, so the base branch is still validated.
  5. Opens one review PR for the middle band, after every developer’s daily PR has merged, so it starts level with the base branch, and requests the area’s owners as reviewers (on Bitbucket the token needs read:workspace to find them by nickname). Merging it accepts them; closing it declines them. With scoring.reviewPrs: lesson it opens one PR per lesson instead, titled with the rule, so each is accepted or declined on its own. Topic files keep their rules sorted by id, so two lessons for one file usually land apart and both PRs still merge; when they don’t, homeostat review merge <n> rebuilds the PR on the base and merges it. Reviewers can do that on the host, or with homeostat review (list, show, merge, close --reason), which works the same on GitHub and Bitbucket and is what /homeostat:review uses.
  6. Opens one dispute PR per flagged rule. A flag takes the rule out of sessions at once; the PR proposes a fix drafted from the flags (a renamed helper, a moved file), keeping the parts nobody disputed. Merging puts it back in force; closing retires the rule, and the next run takes its claims out of the documents that cite it (retractions).
  7. Records the decisions on the homeostat/state branch (homeostat/state-<branch> when sources.baseBranch isn’t main, so each KB branch keeps its own history). Developers’ sessions pick them up and tell them, and the dashboard’s Automation page shows them. Two runs on one base can overlap (the schedule and a manual run): each adds its commit on top of the branch as it is then, never force-pushing, so neither loses the other’s history. Run reports are one file per run, a developer’s decisions are merged record by record (a settled one wins over one still in review, else the later), and heartbeats keep the newer. If another run moved the branch in between, the push is refused and the run fetches and tries again, up to 5 times, then fails saying so.

--dry-run prints every decision, including each update-or-add answer, each judge verdict and each retraction, and writes nothing. --no-reviews skips the model and decides pending lessons only; dispute PRs then propose retiring the rule, nothing is judged, and sections citing a retired rule are listed instead of rewritten. --since <date> backfills: it reads reviews on PRs merged since then, and raises the judge’s cap to cover them. Weights and thresholds: scoring.confidence (config). If Slack is set up, the run posts the review queue each morning and repeats lessons waiting more than 2 days. If notifications.slackWebhookEnv names a variable that is empty in the run (a CI secret not added), the run prints Slack: <NAME> is not set in this CI; no notice was posted and records it in the run report, rather than skipping the notice silently.

agents.maxCostUsd caps what one daily run, deep dive or bootstrap spends on model calls; homeostat daily --max-cost <usd> sets it for one run. There is no cap unless you set one. Before each call the run adds up what it has spent, and once that reaches the cap no further call starts (one already running finishes, so a run can pass the cap by up to one call). The run then finishes normally for what it decided: daily and review PRs open and merge, and decisions are recorded. What it had not decided is left alone, never declined:

  • Daily run: pending lessons stay pending and come back next run; review threads not yet drafted, and drafts not yet judged, are read again next run, which starts reading reviews from the PR the capped run stopped in (or, if older, the PR of a drafted lesson it left unjudged). Merged PRs are read oldest first across all code repos, so a cap stops at one point in time for every repo: a busy repo’s backlog never keeps another from being read. Dispute PRs not yet drafted open next run. With a judge (judgeCalls above 0), drafting stops at 70% of the cap and the rest pays for judging what was drafted, so a backlog bigger than one run’s cap still merges a share every run instead of being drafted again and again; the run prints drafting stopped at $X, 70% of the $Y cap, …; N review threads left for the next run.
  • Deep dive: duplicate pairs and contradiction checks not yet asked wait for next week, and are listed in the PR’s notes.
  • Bootstrap: topics not yet drafted are left; rerunning bootstrap drafts them, since documents already in the KB are skipped.

The run prints, and the daily run records in its run report, stopped at the spending cap: $X of $Y; N lessons left for the next run. The claude-code runtime reports each call’s cost; the api, bedrock and vertex runtimes report tokens only, so the cost is estimated from them with a small price table (per million tokens in/out: claude-opus-5-5 $4/$20, claude-sonnet-5-5 $2/$10, claude-haiku-4-5-20251001 $1/$5; another model by its family, and an unknown one as the dearest) and marked “estimated from tokens”.

Protect the base branch and let only the daily run’s token bypass it (init prints how). If the host refuses the merge, the daily PR stays open for a reviewer, with a commit that lets its pipeline run.

In a KB with topic files, review PRs that share a file conflict once one of them merges. homeostat review merge rebuilds such a PR on the base branch, adding its rules beside the ones already there (and, in documents, combining additions to the same section), waits until the host shows the rebuilt commit (nudging Bitbucket, which can miss a force push, with an ordinary one), and merges it.

To clear a queue you trust, homeostat review merge --all merges every open review PR (and any daily PR the host refused), oldest first, the same way. It first prints each PR’s lesson count and the lessons worth a second look: those the judge found problems in, and those scored below scoring.confidence.accept. Then it asks; --yes skips the question, and without a terminal it refuses unless --yes is given. --except 12,14 leaves those PRs open. Dispute PRs stay open unless you pass --include-disputes, since merging one puts a flagged rule’s fix in force. A PR the host refuses does not stop the rest; the command lists what was not merged and exits 1. Before any merge, one PR or --all, the files the PR changes are validated as they would land: a PR with an error, above all a risk finding nobody has signed off (riskReviewedBy), is not merged, and the command says which entry and how to sign it off. Merging it unread would put an unvalidated lesson on the base and fail every later PR’s build.

The daily run reads pull requests merged in the KB’s code repos (repos, or sources.repos in a KB without repo folders) since its last run (homeostat curate does the same on its own). For each review thread that:

  • was written by a team member
  • was followed by a code change
  • is not already in the KB

it does three things (threads whose lesson is still waiting in an open review PR are skipped, so a lesson is not drafted twice):

  1. Redacts secrets from the thread, then asks the model whether it holds a lasting rule, and drafts one, with its area and its topic from the repo’s list. The lesson is filed under the repo the PR came from. When no topic fits, it goes to general with a suggested topic, listed in the PR for you to add to homeostat.yaml if it is worth one.
  2. Checks documents. If a team document covers the file, it asks whether the thread makes one of its sections out of date, and proposes a one-section patch. Only the three sections the thread’s words and path match best are sent (about 3,000 tokens at most), never whole documents, and at most agents.curator.docPatchCalls (20) threads a run are checked.
  3. Validates the draft, including the secret and risky-content checks.

It never reads source code. It skips PRs marked homeostat:skip, members with optOut, and anything in sources.exclude. homeostat curate --show-prompts prints every prompt and sends nothing.

The same lesson often comes back in other words (“Parse DB URLs by last ‘@’” and, a week later, “Parse the DATABASE_URL by last ‘@’, not urlsplit”). Before adding a new lesson, the daily run:

  1. Finds candidates without a model: the up to three rules (in force in the same repo or core, or waiting in an open review PR) whose words or code overlap the lesson’s most. Code that many rules name counts for less. Two rules are never candidates when each has a number or version the other lacks (v1 and v2, 101 and 102), or they name look-alike code (useFoo and useFooBar).
  2. Asks the model once, only for a lesson with candidates, with secrets redacted: update a rule (the whole rule rewritten to keep everything still true and add the new detail, with a reason), duplicate (nothing new), or create. It must answer create when a number, negation, named entity or condition differs, and never folds in a contradiction.
  3. Applies the answer. An update changes the rule in place (its section in a topic file): same id, owner and first source, the new title and text, confirmed today, and the lesson’s review link added under alsoFrom so the thread is not drafted again. It is scored like any lesson, on the lesson’s own source and author, and lands in the daily PR, shown as Update: with the reason, or in the review PR. A duplicate is declined and reconfirms the rule. A contradiction goes to a reviewer.

At most agents.curator.consolidateCalls (30) calls per run, each a few thousand characters; past that, on a model error, or with --no-reviews, the older word-overlap duplicate check decides. Every call is logged as a model-call event.

Before drafting, the daily run reads the comments on its open one-lesson review PRs. A comment from a reviewer (the named team.reviewers, or seniors and leads) that asks to change the rule, such as generalising it, rescoping it (“all Python code”), rewording it or moving it to another area, is applied: one call to the drafter’s model rewrites the rule as asked (a reviewer may widen it past what the original thread said), the change is committed to the PR’s branch, moved between topic files if its area changed, validated, pushed, and answered on the PR with a line starting ↻ Homeostat (comment <id>). That line is how later runs know the comment was handled. A comment that asks for no change gets a short reply and nothing else. A rewrite that does not validate is left out, and the reply says why. The reviewer still merges.

A confident lesson merges with nobody reading it. Its confidence says the lesson is genuine (a member’s comment led to a code change), not that the drafted rule says only what the comment said, and a drafting model can add things: a value the reviewer never gave, a helper name, a wider scope, “always” where the comment said “maybe here”. Every agent then treats those as fact.

So before a rule drafted from a review merges without a person, the daily run asks a second model to check it against the redacted thread (and, for an update, the rule it rewrites). The judge answers {ok, problems: [{trap, evidence}]} for four traps:

Trap The rule
invented-detail states a value, name, path, number or reason the thread does not give
wider-scope applies to more than the comment addressed
overgeneralised turns a one-off or conditional suggestion into “always” or “never”
contradicts-thread says the opposite of what the thread asked or concluded

A pass costs one call. A failure is asked twice more and kept only when most of the three answers fail the rule, so one noisy answer neither blocks nor passes it (the pattern is adapted from Hindsight’s eval judge, MIT, where a trap is the side that gets re-asked). Current models take no temperature setting, so the re-asks vary by the model’s own sampling.

  • A kept failure is narrowed first. The drafter’s model rewrites the rule to say only what the thread supports, given the judge’s findings, and the judge checks the new wording. If it passes, the narrowed rule merges, and its decision says what was narrowed. (In a team trial, reviewers merged two in three of the rules the judge had failed as they were: most needed a narrower wording, not a person.) Narrowing costs one drafter call and the judge’s calls again, from what the spending cap keeps for judging.
  • A failure that is still kept loses scoring.confidence.penalty.judge (30) points and goes to the review PR, whose body lists each problem with the judge’s evidence.
  • A kept contradiction (most answers name contradicts-thread) is declined: the rule teaches the opposite of its own evidence, a reviewer would have to rewrite it from the thread anyway, and the decline and the evidence are in the daily PR for anyone who wants to write it by hand.
  • Unjudged is not passed: a judge that errors or gives no usable answer, and drafts past agents.curator.judgeCalls (100 calls; keep it above the lessons a busy day merges on its own), go to a reviewer. judgeCalls: 0 turns the judge off, and drafts merge on confidence alone as before. When drafts go to a reviewer for want of calls, the run prints how many and suggests raising judgeCalls, and records it in the run report.
  • Backfills are judged, not queued: with --since, a run can draft weeks of reviews at once, far more confident drafts than judgeCalls covers. The cap then rises to enough calls for every confident draft, even if each fails and is asked three times (max(judgeCalls, 3 × confident drafts)), and the run prints judge cap raised for the backfill. The spending cap still applies: past it, drafts are left for the next run rather than sent to review.

A review thread whose lesson was declined, by the run or by a reviewer closing its PR, is never drafted again: the decision records the thread, and the next runs skip it with “its lesson was declined before”.

Only these lessons are judged: the middle band already goes to a person, and lessons written by hand have no thread to hold them to. The judge’s model is agents.curator.judgeModel; by default claude-sonnet-5-5 under an Opus drafter and claude-haiku-4-5-20251001 under a Sonnet one (never a dearer model than the drafter: it runs up to judgeCalls times a day), on the same runtime as the drafter (with claude-code the model is passed to claude --model). The run warns when you set it to the drafter’s own model. Each call is a few thousand characters and is logged as a model-call event with agent judge. Haiku takes no effort setting, so with a Haiku judge judgeEffort is ignored and thinking is off: a judge answer is about 600 output tokens (measured: about 6,000 with Claude Code’s default thinking budget).

A retired rule stops reaching sessions, but a document section written from it keeps saying it. When a run retires a rule, it looks for the sections that cite it:

  • by id: the rule’s id in the section’s text, as a whole word (a one-word id only in a credit form such as (id) or `id`), or
  • by provenance: the section’s url is the rule’s review thread (or one folded into it).

It never matches on words alone: a section that talks about the same thing may rest on other evidence, and deleting something still true is worse than leaving something stale (the approach is adapted from Hindsight’s retractions, MIT). For each citing section it asks the model, secrets redacted, for the section’s text with only what rested on the retired rule taken out; the rules in force that the section also cites are sent as still supported. If nothing rests on it, there is no patch, only a line in the output. A rewrite is kept only when it applies as a one-section patch, validates, no longer cites the rule, adds nothing (at most a joining word or two) and leaves text behind; its provenance becomes source=curator (or deep-dive) with note="retracted <id>", keeping the old url unless it was the retired rule’s thread. Anything else is listed for a person.

  • Daily run (a dispute PR closed): the retirement merges in the daily PR as before, since a reviewer decided it; the document patches go in the same run’s review PR, because documents never merge on their own. At most agents.curator.retractCalls (10) calls.
  • Deep dive (a rule retired as stale): the patches go in the deep-dive PR with the retirement. At most agents.deepDive.retractCalls (10). A rule merged into another as a duplicate is not retracted (the survivor says the same); its citations are listed so the id can be updated.

Nothing is scheduled there. The Claude Code plugin’s hooks run homeostat sync in the background: when a session starts, on a prompt at most every 3 hours (sessions often stay open for days), and when a session stops with new lessons. A sync pulls the team KB, clears lessons the daily run has decided, and pushes new ones. Its fetches and push give up after 2 minutes (HOMEOSTAT_GIT_TIMEOUT_MS), and over ssh within about 20 seconds of a dead connection, so a sleeping laptop or a dropped network never leaves one hanging. homeostat learn lessons apply to the author’s own sessions at once and reach the team the next morning.

homeostat deep-dive:

  • merges near-duplicate rules into the stronger one (fewer now that the daily run updates rules instead of adding siblings; pairs with different numbers or look-alike code names are never merged)
  • asks the model whether this week’s rules contradict older ones, and marks them
  • retires rules nobody has confirmed in staleAfterDays, and rewrites the document sections that cite them without the retired claims (retractions)
  • notes document coverage gaps and stale sections

Its changes go in one PR, which a person merges. --no-llm skips the model; sections citing a retired rule are then listed in the PR instead.

homeostat seed <checkout> --area <area> --owner <member> drafts first documents from a scan of one local repository: architecture, conventions and cross-repo from the file tree and key files (README, manifests, build files), data from schemas, ORM files and migrations, infrastructure from charts, Terraform, manifests and pipelines. Secrets files are never read, sources.exclude.paths applies and the rest is redacted. seed and bootstrap, which runs it for every repo along with importing their existing docs, are the only commands that send code to a model, so nothing schedules them. --dry-run prints everything it would send. Each document arrives as its own PR, every section marked source=seed with the model’s confidence.

homeostat digest posts a Slack message to the webhook named by notifications.slackWebhookEnv. It lists:

  • new and reconfirmed rules, and decisions the team rejected
  • updated document sections
  • the review queue and this week’s flags
  • the tool names agents guessed

It is built without a model. --dry-run prints it instead. If the variable is not set in the run, it prints Slack: <NAME> is not set in this CI; no notice was posted and fails.

agents.runtime decides where prompts go:

Runtime Where prompts go
api (default) Anthropic’s API, with a Console key
bedrock Claude in Amazon Bedrock, in your AWS account
vertex Claude on Google Cloud, in your project
claude-code A Claude Code login; for personal use or Team/Enterprise plans

Each model call is logged locally by size, never content. Details: runtimes.

Where How
GitHub Actions init writes homeostat-curator.yml (the daily run; the name is kept from 0.1), homeostat-deep-dive.yml and homeostat-digest.yml. The token is the Actions secret HOMEOSTAT_GIT_TOKEN (the old HOMEOSTAT_GITHUB_TOKEN still works), else the workflow’s own GITHUB_TOKEN, which reaches the KB repo only
Bitbucket Pipelines init writes custom pipelines (homeostat-curator runs the daily run, a name kept from 0.1 so existing schedules keep working); schedule them under Repository settings → Pipelines → Schedules. The token is the secured variable HOMEOSTAT_GIT_TOKEN: an access token, or an Atlassian API token with the email of the account that created it in HOMEOSTAT_GIT_EMAIL (which token). The names up to 0.3.2, HOMEOSTAT_BITBUCKET_TOKEN and HOMEOSTAT_BITBUCKET_EMAIL, still work
One shared box homeostat up runs the daily run and the deep dive on their cron schedules and the digest on digestDay, and serves the dashboard. The Docker image does the same

homeostat setup stores the tokens as CI variables and, on Bitbucket, creates the schedules through the Pipelines API; homeostat setup --check says what is still missing (setup). Check a setup’s security with homeostat doctor --security.