NAME

App::karr::Foundation - Single-shot foundation daemon -- periodic agent execution across karr boards

VERSION

version 0.600

SYNOPSIS

# Typical cron entry -- run every 5 minutes
*/5 * * * * /path/to/karr-foundation

# Force a run regardless of board state
karr-foundation --force

# Preview what would run
karr-foundation --dry-run --verbose

# Read-only overview of every board (no agent runs)
karr-foundation --status

# Write the fleet's plan into the hub, and execute it out of there
karr-foundation plan < chain.yml
karr-foundation chain

DESCRIPTION

karr-foundation is a single-shot, idempotent CLI meant to be invoked periodically (cron, systemd-timer, while-loop). It scans configured karr boards, detects changes or open work, and drains each board by invoking the configured agent command repeatedly until no actionable task remains.

Using this class as a library. bin/karr-foundation is what most callers run, and it is also where karr's character/octet boundary gets set up (see App::karr::Encoding) before any command code runs: a :encoding(UTF-8) layer goes on STDOUT/STDERR, and @ARGV is decoded before new_with_options reads it into option values. This class does not repeat either step -- both are the program's decision, not one a class it merely loads should make for it (see "enable_std_utf8" in App::karr::Encoding and "decode_argv" in App::karr::Encoding). A caller that loads App::karr::Foundation directly, instead of invoking that script, is responsible for both:

use App::karr::Encoding qw( decode_argv enable_std_utf8 );

enable_std_utf8();
decode_argv();
App::karr::Foundation->new_with_options->run(@ARGV);

Skipping the handles does not fail outright: every fixed message this class prints or warns is plain ASCII (ticket #214). What it does not cover is data -- a non-ASCII repo path folded into a skip $repo -- $wait line, or a YAML error carried through clean_error into a warn -- which still risks Wide character in print/warn the first time it reaches a handle nobody configured. Skipping @ARGV is quieter, not safer: option values built from it hold raw UTF-8 octets instead of decoded characters, with no warning to say so.

Config file: ~/.config/karr-foundation/config.yml (or --config).

dirs:
  - /path/to/repo1
  - /path/to/repo2

scan:
  - /path/to/parent-dir   # finds all direct subdirs that have a .karr file

concurrent: 4             # boards that may have an agent at once (default: 1)
hub: /path/to/hub-repo    # the repository carrying refs/karr-foundation/*
routing: >-               # prose for the coordination agent, never parsed
  minimax is cheap; never hand it a release.

Per-repo .karr file:

claude: true              # synthesize the canonical claude command (opt-in)
claude_bin: claude        # binary for claude: true (default: claude)
claude_max_turns: 30      # --max-turns for claude: true (default: 30)
claude_permission_mode: bypassPermissions   # (default: bypassPermissions)
prompt: >-                # agent instruction, exposed as $PROMPT
  Use the karr-coordinator skill: pick the next actionable task and move it.
command: claude -p "$PROMPT"   # explicit command; wins over claude: true
on_idle: skip             # 'skip' (default) | 'always-run'
max_runtime: 1800         # seconds: per-command SIGKILL (0 = no limit)
mode: drain               # drain (default) | single | ticket
drain: true               # older spelling of mode: true=drain, false=single
max_attempts: 2           # stalls on one task before auto-block (default: 2)
max_iterations: 50        # hard cap on drain iterations (default: 50)
cooldown_base: 1          # cooldown minutes at level 0 (default: 1)
cooldown_max: 64          # cooldown ceiling in minutes (default: 64)
error_patterns:           # extra case-insensitive substrings -> common-error
  - my custom api error   # (added to the defaults; matched as written)
on_drained: ./release-gate.sh   # run when the board has no work left
on_drained_max_runtime: 1800    # seconds for that command (0 = no limit)
on_drained_max_rounds: 3        # see "The domain hook" (0 = no cap)

agent: minimax            # a named agent from the config's 'agents:' section

claude, claude_bin, claude_max_turns, claude_permission_mode, command, mode, on_drained, on_drained_max_runtime, on_drained_max_rounds and prompt/default_prompt may also be set globally in the config file; the per-repo .karr value wins.

Named agents. A board has one command. A fleet has several agent commands with different strengths and different failure modes, so the config can name them and a board can pick one:

agents:
  minimax:
    command: claude_with_minimax
    kind: claude-code       # the invocation contract; default: shell
    probe_every: 15m        # optional -- see "Agent availability" below
    permission_mode: bypassPermissions    # kind: claude-code only
    max_turns: 30                         #   "     "        "
    allowed_tools: [ Bash, Edit ]         #   "     "        "
    concurrent: 2           # runs of THIS agent at once -- see "Concurrency"
    description: >-
      Prose. What this agent is good at, where it is weak, what it costs.
  planner:
    command: claude
    kind: claude-code
    role: coordinator     # the fleet's judgement layer -- see below

default_agent: minimax    # for boards whose .karr names none
probe_every: 10m          # fleet-wide default for agents that name none

description is never read by karr. It is carried for the agent that routes work across the fleet: the thing choosing is a language model, and it reads prose better than it matches taxonomies, so there are no classes and no enums here. karr-foundation --status --verbose prints it.

Agent definitions are local and only local. They are not board state and never sync: an agent command that exists on one machine does not exist on the next, and an account limit is a property of a person, not of a project.

agent: resolves below the literal command strings and above claude: true -- the full order is --command, default_command, the .karr command, the .karr agent, the assignment (see "The coordination agent" below), default_agent, claude: true. A board that names an agent the config does not define is an error that skips that board, not one that silently stops running.

Invocation contracts. kind says what karr may append to a definition's command:

  • shell (the default) - the command is a complete shell template and karr appends nothing. This is what a .karr command has always been: karr cannot know what the thing at the other end understands.

  • claude-code - karr appends -p "$PROMPT", an output format, and --permission-mode, --max-turns and --allowed-tools from the definition. Permission escalation is therefore a property of the agent definition rather than something baked into a wrapper script.

The output format is stream-json --verbose --include-partial-messages, not plain json, and that is the one deliberate choice in this contract. karr needs the run's own report (see "The run's own report" below), which only a structured format emits -- but plain json prints nothing at all until the run ends, which would silently cancel the live output promised under "Live output" below on exactly the runs that take half an hour. stream-json ends with the same result object and streams on the way, so App::karr::Foundation::Runner renders the assistant's text out of it for the terminal and .karr.log while the raw stream is what the run is classified from. The ticket of a mode: ticket run is not appended: claude-code has no flag for it, so the id keeps travelling as a closing sentence in $PROMPT and as $KARR_TASK.

Agent availability. karr keeps the least it can per named agent: ok, or failing since a moment with a next attempt due at another. No cost, no tokens, no quotas -- a rate limit and an exhausted budget look identical from the outside (the command stops working), so one mechanism covers both, and every other reason a command can stop working comes along for free.

A drain that ends in a common-error marks its agent failing; any other outcome says it works. While an agent is failing, every board that uses it is skipped -- the fact is about the command and the machine, not about a repository, so two boards on one agent share the outage instead of each burning a window rediscovering it. That is a level above the per-board cooldown, which keeps working exactly as before underneath it. Like the cooldown, --force does not override it; the wait is bounded by probe_every and ends by itself.

When the next attempt comes round the agent is simply run again on the work that was waiting: the probe is the run. Where the reset rhythm is known it is configured as probe_every; where it is not, the retry is at a fixed interval and every recovery is recorded -- from when it broke to when it worked again, with what it looked like. Reading a pattern out of those records is the coordination agent's job, never a learning algorithm inside karr.

The record lives beside the config file that defines the agents (agents.state next to config.yml, so --config relocates it), because it belongs to neither of the two obvious places: .karr.state is per repository and this is not, and the board's own config syncs, which would push one person's spent limit at everybody else's fleet.

Concurrency. By default karr-foundation works one board at a time, which is what it has always done. concurrent: in the config raises that ceiling, and three levels bound what actually runs -- the tightest one wins (App::karr::Foundation::Limits):

  • concurrent: in the config -- the machine ceiling. It protects this box's CPU and memory and is not a quota; it says nothing about what any account may spend. Default 1.

  • concurrent: on a named agent definition -- the operator's estimate of where that agent's session limit sits. It is a guess and is allowed to be wrong: being wrong makes the agent start failing, which marks it so (see "Agent availability" above), skips every board on it for one probe interval, and lets the fallback take over.

  • limits: in the chain header, for the fleet's current plan:

    limits:
      concurrent: 4
      per_agent:
        minimax: 2

    The names under per_agent are agent definition names. One this machine does not define is dropped with a verbose note rather than refused -- agent definitions are local and only local, so a chain written where minimax exists reaching a machine where it does not is the expected case.

One hard rule stays: one agent per repository. The unit of concurrency is one board, run by one forked child that owns that board's .karr.lock for the length of its drain. Two agents in one working tree would collide over the index and the checkout, so concurrency is across repositories and never inside one; anything else would need a git worktree per agent and is deliberately out of scope. Several ticks knocking at the same board at the same time is the case .karr.lock already answered (#162) and still answers: the flock(2) admits exactly one.

A signal to karr-foundation takes every running agent with it. The parent sends SIGTERM to its children and each child runs the same shutdown path a serial run does -- TERM then KILL to its agent's process group (#148), then release its lock -- rather than being killed outright, which would leave every agent reparented to init.

--dry-run stays serial whatever the ceiling says: it starts no agent, so concurrency would buy nothing and cost its output the order it is read in.

The hub. hub: names the one repository of a fleet that carries refs/karr-foundation/* -- the chain of planned steps and the run logs (App::karr::Foundation::ChainStore). That namespace is pulled once at the start of a run, before anything reads it, so the limits a tick applies are the fleet's current ones and not whatever this machine last happened to fetch. An ordinary tick pushes nothing back: it reads the chain header for those limits and writes no step state. Executing the chain is karr-foundation chain, a command of its own (see below), and that one does write and does push.

Running the chain. karr-foundation chain is the VM of the design's "the AI is the compiler, the chain is the program": it takes the steps the plan says are ready, checks each precheck against facts it measures, runs kind: ticket steps through ticket mode in the target repository and kind: shell steps as a command under that repository's own lock, and writes each step's state and the run log back to the hub.

It is deliberately not a fourth mode: beside drain, single and ticket. Those are per-repository settings and the chain is fleet-wide, so a mode: chain in a .karr file could not answer the only question the chain poses -- which step of the DAG is next. The executor is therefore the caller of those modes, and a chain step inherits the board lock, the claim discipline, the ownership guard and the run's own report from the mode it calls rather than carrying a second copy of them.

It is also a command rather than something an ordinary tick does on the side: karr-foundation with no arguments has meant "drain the boards in my config" for as long as it has existed, and picking the chain up automatically would have changed what every cron entry in a fleet does on the day somebody wrote one.

karr-foundation chain              # execute what is ready
karr-foundation chain --dry-run    # list the ready set and its verdicts

With no hub: configured this is an error and not a quiet no-op, exactly as the mailbox commands are: the chain is fleet state, and executing a plan nobody else can see is not a smaller version of executing the fleet's plan. With a hub but no chain written, it says so and returns 0 -- a fleet nobody has planned for yet is a normal state, not a failure. The full argument, the fact vocabulary a precheck may use and what a failed step does to the DAG are in App::karr::Foundation::Executor.

Writing the chain. karr-foundation plan is the other half of that command: it reads a chain as one YAML document on stdin -- or out of the file --input names -- and replaces what the hub holds with it.

karr-foundation plan < chain.yml            # replace the chain
karr-foundation plan --dry-run < chain.yml  # check it, write nothing

steps:
  - id: 1
    kind: ticket
    repo: /srv/karr
    ticket: 41
    precheck: ticket_status == todo
  - id: 2
    kind: shell
    repo: /srv/karr
    needs: [ 1 ]
    command: ./release-gate.sh
limits:
  concurrent: 2
note: what this plan is for

A document rather than options, because a chain is a DAG and a DAG is nested: options that described one would be YAML with a worse syntax and a parser of its own, and the writer that matters most -- the coordination agent -- already produces structure. JSON is read by the same parser and needs no flag of its own. A bare list of steps is a document too: that is what "write_chain" in App::karr::Foundation::ChainStore's own first argument looks like, so a planner that wrote only steps wrote a whole document.

It replaces the chain rather than adding to it, which is what the header already means: only steps whose chain id matches the header are ever ready, so appending would be a new chain over the old steps plus the new ones, with a merge policy of its own for an id that is already there and a state that has already been reached. The plan is what the planner currently thinks. What makes replacing safe is the guard: a chain that still has a step in state running is refused unless --force, and the whole document -- every step, the ids, the edges, the cycle check -- is validated before the first ref is written, so a chain karr will not take leaves the one in the hub exactly as it was ("validate_chain" in App::karr::Foundation::ChainStore).

The command is what an agent gets because everything else karr asks an agent to do is a command. Before it, writing a chain was write_chain from Perl and the coordination agent was handed that one-liner in its prompt to type out -- the one place karr gave an agent Perl instead of a call, where a rename in a storage class broke a prompt and nothing said so (#213).

The question mailbox. A question is a file with an answer field, not a dialogue, which is what removes the special case for "a human happens to be present". karr-foundation ask writes one into the hub and returns; the chain carries on with everything that does not depend on it, and only the steps that do wait. Whoever answers -- a person at a terminal, a chat bridge, the coordination agent -- types karr-foundation answer ID ANSWER and needs to know nothing about the chain. One mailbox, many writers.

karr-foundation ask "Which registry do we publish to?" \
    --context "the release gate is waiting" \
    --options cpan,darkpan --default cpan --policy use_default --wait 3600

karr-foundation answer 7 darkpan --note "this release is a private one"

--policy is what happens when nobody answers: block (the default: wait), use_default (--default becomes the answer once --wait has passed) or escalate_to_ai (the coordination agent decides). Both commands sync the fleet namespace around what they write, and --status lists the open mailbox with the id each one is answered by. The storage, the retention and the argument for why an answer is its own ref rather than a field in the question are in App::karr::Foundation::Questions.

The coordination agent. The third layer of the design and the only one that is an AI: coordination is shared state in refs, execution is local, and judgement -- planning, routing, reacting to what nobody planned for -- is an agent. It is an agent like every other one: an entry in agents:, invoked through its own command under its own kind contract, classified from its own result object, and marked failing by the same availability record. What sets it apart is when it runs, which is never in the hot path. karr-foundation works through written plans by itself and calls this one only where a plan is missing or has broken. Between two of those, no AI runs at all, and that is what makes the arrangement affordable.

Which agent it is, is a marker on the definition:

agents:
  planner:
    command: claude
    kind: claude-code
    role: coordinator

and not a second config key naming an agent that is already named. Two marked definitions are refused rather than guessed between; role: with anything else in it is a config error, because a typo there would leave a fleet with no judgement layer at all and say nothing about it.

There are four deviations, and every one of them was already a place that recorded "the planner is wanted" and nothing else: a kind: plan step, a question past its deadline whose policy is escalate_to_ai, a step whose precheck no longer holds (stale), and a repository the assignment cannot route. The first three come out of the chain executor, the fourth out of agent resolution. A tick collects them and makes one call at the end of itself, carrying all of them: a tick that met five deviations has learned one thing -- the plan is out of date -- and five calls would pay five times to hear it. The call is last because a planner called half way through would be planning against a board the tick was still moving, and nothing is re-read afterwards: what it wrote is what the next tick runs.

The run happens in the hub, under the hub's own .karr.lock (one agent per repository holds there as everywhere), with KARR_ROLE=coordinator so its own karr writes stay out of a board agent's activity log, and with its instruction in $PROMPT: the deviations, where the fleet's files are, the agent list with each one's availability and prose, and the operator's own prose from the config's routing: key. That prose is the routing criterion and karr never parses it -- the thing choosing is a language model, and it reads better than it matches taxonomies.

The assignment is what it writes so that routing needs no AI afterwards:

repos:
  /path/to/repo:
    - minimax
    - claude
    - WAIT

Repository path to an ordered list of agents, with an explicit WAIT for "rather wait than use anything further down". karr-foundation looks the repository up and takes the first entry that currently works; a chain that reaches WAIT, or whose agents are all failing, means the board runs nothing this tick and says so (agent-waiting in the overview) rather than reading as a board nobody configured. --force does not override that, for the same reason it overrides neither the cooldown nor an agent's availability: the wait is bounded and ends by itself. It sits below a board's own agent: -- a board that names one has said the most specific thing there is to say about itself -- and above default_agent, which is per fleet where this is per repository.

Like the agent definitions it names, the assignment is local and never in refs: an agent command that exists on one machine does not exist on the next, so a table naming agents cannot be shared any more than they can. It lives beside agents.state and the config, as assignment.yml, and follows --config with them. A fleet that marks no coordinator behaves exactly as it did before any of this existed -- the deviations are printed, and the operator is the planner. The details are in App::karr::Foundation::Coordinator.

Run mode. mode says what one pass over a repo is:

  • drain (the default) - run the agent again and again until the board stops moving. This is what karr-foundation has always done and what "Drain semantics" below describes.

  • single - exactly one agent run; the agent still chooses its own work.

  • ticket - exactly one agent run, about one card foundation names.

drain: true|false is the older spelling of the first two and stays honoured: true means drain, false means single. Two keys that both meant "one run" would be a trap, so they are one key with an alias rather than two switches -- mode is asked first, drain answers only when mode is absent, and a per-repo drain still beats a config-wide mode. An unrecognised mode is an error that skips the repo, never a silent fallback to draining it.

Ticket mode. Before the agent starts, foundation picks the card the run is about -- App::karr::Foundation::Picker, applying karr pick's eligibility and ranking (not terminal, not blocked, not held by a live claim; class, then priority, then id). It is told to the agent twice: spliced into $PROMPT as a closing sentence naming the id, and exported as $KARR_TASK for a command template that wants the bare number. Nothing is appended to the command itself -- how arguments are appended belongs to the per-agent contract (kind:), which is a separate piece of work, and an environment variable works with every template that exists today.

Foundation names the card; it does not claim it. The claim is the agent's work session, minted with karr agentname and reused across its own move and handoff (#176), and the board's per-repo lock plus the one-agent-per- repository rule already keep anybody else off the card for the length of the run. So an agent that dies mid-work leaves at most its own claim -- released by claim_timeout, or by karr unlock for a pick lock -- and costs one attempt on foundation's counter.

The run is then judged by that card and not by the board hash: progress when it moved (status, claim or updated changed, or it left the actionable set), stall when it did not, whatever else on the board did move. A stall bumps the card's attempt counter and auto-blocks it at max_attempts, under the same ownership guard as a drain -- a card somebody else took during the run is never blocked on foundation's say-so. With no assignable card at all, ticket mode runs no agent, logs TICKET none assignable, and returns idle; --force and on_idle: always-run force the check, not a run without a card.

Board-level disable. A board can opt out of automated agent runs in its own karr state -- foundation.enabled in refs/karr/config, set with karr disable [--reason "why"] and cleared with karr enable. Because the flag is board state it syncs with the board, so every foundation instance on every machine honours it. A disabled board is skipped whole: the flag is checked before the agent command is resolved and before the drain decision, so there is no drain, no auto-block and no agent run. It therefore wins over --command, the config's default_command, the .karr command and claude: true, and --force does not override it. Use it for a repository whose backlog is parked (an abandoned project kept for reference) that a globally configured default_command would otherwise drain. --status shows such a board with a disabled flag and its reason.

The domain hook. When a board has drained, on_drained runs a configured command in it. karr does not know what that command does, and must not. In the fleet this design came from it starts a release gate that builds a distribution, installs it, tests every dependent against it and raises version requirements -- none of which belongs in a kanban tool, and all of which would otherwise arrive here as rules about what an exit code means. So the exit code is written to .karr.log and .karr.state and interpreted by nobody: a hook that fails does not park the board, does not mark the board's agent failing, and is never the run's last_error. It is not an agent run and is not classified as one -- no report is read out of it, no error pattern is matched against it, no ticket is assigned to it.

It is told where it is and nothing else: KARR_REPO, and KARR_ROLE=hook so that karr writes of its own land in their own activity log rather than counting as the agent's engagement with a card. PROMPT is empty (the prompt is the agent's instruction) and so is KARR_TASK. It runs in the board's directory, under the board's own .karr.lock, with the same process-group kill and the same tee to .karr.log an agent gets -- a gate that backgrounds a build must not outlive the run that started it -- but with its own budget, on_drained_max_runtime, because how long an agent may take says nothing about how long a release gate may.

Drained is a fact about the board, not a name for an outcome: no actionable task is left on it -- everything done, archived or blocked. That is deliberately the same question --force and on_idle: always-run are answers to, and it is the only one that stays meaningful across the run modes. A drain that ends in a common-error does not count: a rate-limited agent leaves a board that looks exactly like one it worked through, and foundation does not believe that run itself.

An empty board is not the same as finished work. The hook may fail and file tickets, at which point the board is no longer drained; the next tick works them, the board drains again, and the hook is asked again. That cycle is the point -- a gate that reports what it found and is re-run once it is fixed is what the hook is for -- so the two guards below bound it rather than forbid it:

  • The same board is not asked twice. The board fingerprint the hook last ran at is kept in .karr.state; a board that has not moved since gets no second run. Without this, a repository nobody touches would start a release gate on every cron tick for ever, because a drained board stays drained.

  • A chain that never settles is capped. Every hook run that puts work back on the board changes the fingerprint, so the first guard cannot see the loop of "hook files a ticket, agent works it, board drains, hook files another". Consecutive rounds in which the hook itself made work are counted; a run that leaves the board alone -- the gate that finally passed -- clears the count, and at on_drained_max_rounds (default 3, 0 disables) the hook is suppressed with a line in .karr.log saying so.

--force overrides both. They are statements about board state, which is what --force is documented to override, and unlike the cooldown and the agent availability the cap is not time-bounded and does not end by itself -- so it needs a way out, and the operator is it.

Coordinator and overview. Agent execution is opt-in -- a board runs an agent only via command, a named agent or claude: true. When no board has an agent configured, the default action is a read-only overview of every board (status counts, in-progress/blocked tasks, lock and cooldown state, which agent a board uses and whether it currently works); a human can use foundation purely to coordinate their own work. --status forces the overview regardless of configuration.

Live output. When run interactively (TTY) or with --verbose, the agent's output is streamed to the terminal in real time as foundation reads it; it is always appended to .karr.log regardless of TTY. To shape what is shown, the command may emit stream-json and filter it, e.g.:

command: >-
  claude -p "$PROMPT"
    --output-format stream-json --verbose --include-partial-messages
    --permission-mode bypassPermissions --max-turns 10
  2>&1 | jq -r 'select(.type == "stream_event") | .event.delta.text // empty'

Set max_runtime: 0 in .karr to disable the per-run timeout entirely (agent runs until completion with no SIGKILL).

Drain semantics. Each iteration runs command once, then classifies the result from what foundation can observe -- the run's own report where it made one, otherwise the exit code, board ref movement, and the run's captured output:

  • progress -- the board changed; keep draining.

  • stall -- a task this run's agent engaged did not move. That task's attempt counter is bumped; at max_attempts it is auto-blocked (blocked: auto-block: no progress after N attempts (foundation)) so it drops out of the actionable set and the drain can finish. The agent may always set a better reason itself with karr edit --block; the auto-block is a fallback.

    Engaged means foundation can prove the agent worked on that card during this drain: the agent runs with KARR_ROLE=agent, so every karr write it makes is recorded in the board's own activity log under the agent identity, and only the tasks named there -- held by nobody, or by a claim name the agent itself wrote under -- can be penalized. A card somebody else holds is never touched, and neither is one the agent merely left claimed in an earlier run: a stale claim is what claim_timeout and karr unlock are for. Where that evidence is missing altogether -- an agent that does not write through karr, an unreadable log -- foundation auto-blocks nothing rather than guess: the drain then simply ends on its iteration cap, which is far cheaper than blocking a human's in-progress card out from under them (#158).

  • common-error -- a non-zero/timeout exit, or an error pattern in the output of a run that moved nothing (rate limit, auth, network, 5xx, ...). No task is penalized; the repo enters an exponential cooldown (cooldown_base x 2^level minutes, capped at cooldown_max, reset on the next clean run) and is skipped until it expires.

    What the run did is asked before what it printed: a run that exited 0 and moved the board is progress whatever text scrolled past, and is never reclassified by its own transcript. The scan is evidence only where there is no other -- a run that produced no board movement at all, which is what a rate-limited or unauthenticated agent looks like. A pattern seen in a run that did move the board is noted in .karr.log and otherwise ignored.

    The default patterns are correspondingly narrow: a symptom word counts next to a failure word on the same line ("network error", "invalid credentials", "quota exceeded"), not on its own, and an HTTP status counts only where something adjacent marks it as one ("API error: 429", "429 Too Many Requests"), not in a diffstat or a line number. Before this, an agent that printed its own board tripped the scan on a backlog title, and a diffstat of 403 changed lines tripped it on 403 (#160).

  • idle -- the agent did nothing and grabbed nothing; stop.

The run's own report. An agent invoked with --output-format json ends its output with one line: a JSON object saying whether the run failed, how it ended, how many turns it took, how long it ran and what it cost. Where a run leaves one, foundation classifies from it and the text scan below does not run at all.

Foundation is not configured for this and does not inspect the command string for it -- it reads the tail of the output, because that is where the format puts its result and nothing else has to be kept in step with anything. Only the last non-empty line counts: prose before the object is irrelevant, prose containing one cannot be mistaken for it (an agent printing a board can print a pasted result object the same way #160's board printed a 503), and anything after it makes the run unstructured again, so the scan takes over. The reasoning is written out at _run_result in App::karr::Foundation::Runner.

A reported error ranks with the exit code, not with the scan: it is the run's statement about itself, not an inference drawn from its prose, so the "what it did before what it printed" guard below does not apply to it. Its kind decides what happens next. A provider status (api_error_status) is the case the scan was written for and backs the board off as a rate limit always did. A spent turn budget (error_max_turns) is not: the agent worked, the provider answered, and the task was simply larger than the budget it was given -- so it is logged, the board is not parked, and the run is judged by what it moved. Any other reported error keeps its own name (error_during_execution) and cools the board down. A non-zero exit a report of success does not account for is still a common error: the report is the agent's, the exit code may be its wrapper's.

In ticket mode the report is what finally separates the two stalls that used to look identical -- "the agent reports it could not proceed" and "the agent did nothing" -- and .karr.log names which one it was (STALL task#N -- the agent ran out of turns). With no report it says exactly that rather than guessing.

All per-board state files are gitignored: .karr.state (board hash, per-task attempts, cooldown, last error, last report, and the hook's board fingerprint, round count and last exit), .karr.lock, .karr.log. Agent availability is not among them: it is not per board and does not live in the repository at all (see "Agent availability" above). last_error describes the last run and is removed again by the next run that is not a common error, so it never outlives the cooldown it caused. last_result is the same for the report -- how the last run ended, its turns, duration and cost -- and is dropped again by a run that reported nothing.

run

exit App::karr::Foundation->new_with_options->run(@ARGV);

The single entry point, invoked by bin/karr-foundation. What is left of @ARGV after option parsing is a hub command and is answered first: ask and answer (see "The question mailbox" above) work on the hub alone, discover no board and start no agent, and chain (see "Running the chain" above) executes the fleet's plan through App::karr::Foundation::Executor. An argument that is none of the three is a user error rather than a silent drain. With no arguments -- how cron invokes it -- it is one pass over every configured repo, then returns -- there is no internal loop; running periodically is left to cron/systemd-timer/an external while loop, per "DESCRIPTION". Returns 1 (a process exit code, not an exception) when _discover_repos finds nothing at all -- an empty dirs/scan in the config, or a config file that does not exist -- and 0 otherwise, including when individual repos error out: a repo whose _process_repo dies is warned and skipped, never propagated, so one broken board cannot stop the rest of the run.

With --status it prints App::karr::Foundation::Overview's read-only overview and returns without touching any board. Without it, run first checks whether any repo has an agent configured at all (per repo, _agent_command, excluding boards disabled via karr disable); if none do, it falls back to the same overview instead of doing nothing, since agent execution is opt-in and a config with no agents configured is a legitimate way to use foundation purely as a status board. Otherwise it calls _process_repo for each repo, which is what applies the disable flag, the lock, the cooldown, the change/actionability check, and finally the drain loop described under "Drain semantics" above.

One repo at a time unless the effective machine ceiling says otherwise, which is what it says by default -- see "Concurrency" above for the three levels and App::karr::Foundation::Limits for how they combine. Above 1, each board gets a forked child running the whole of _process_repo for it, the parent schedules within the global and per-agent caps, and the shutdown handler TERMs the children rather than their agents so every board runs the cleanup it would have run serially.

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/karr/issues.

IRC

Join #langertha on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is Copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)