NAME

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

VERSION

version 0.500

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

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.

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

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)
drain: true               # loop until drained (default) | false for single run
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)

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

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.

Coordinator and overview. Agent execution is opt-in — a board runs an agent only via command 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); 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 — 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 × 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.

All state files are gitignored: .karr.state (board hash, per-task attempts, cooldown, last error), .karr.lock, .karr.log. 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.

run

exit App::karr::Foundation->new_with_options->run;

The single entry point, invoked by bin/karr-foundation. 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 in turn, 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.

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; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.