App-karr
view release on metacpan or search on metacpan
lib/App/karr/Foundation.pm view on Meta::CPAN
# The mailbox commands (#191) are positional, and MooX::Options hands the
# leftovers back in @ARGV only when it is allowed to consume the options out
# of it. Nothing else in this class reads @ARGV, and F<bin/karr-foundation>
# passes what is left to run().
protect_argv => 0,
);
use App::karr::Error qw( user_error clean_error );
use App::karr::Encoding qw( from_octets yaml_load );
use App::karr::Role::ExitCodes;
use Path::Tiny;
use IO::Handle;
use POSIX qw( SIGTERM SIGINT SIGHUP WNOHANG );
use YAML::XS ();
use Time::Piece;
use Digest::MD5 qw( md5_hex );
use Try::Tiny;
use App::karr::Git;
use App::karr::BoardStore;
use App::karr::ActivityLog;
use App::karr::Foundation::Runner;
use App::karr::Foundation::State;
use App::karr::Foundation::Overview;
use App::karr::Foundation::Picker;
use App::karr::Foundation::Agents;
use App::karr::Foundation::ChainStore;
use App::karr::Foundation::Executor;
use App::karr::Foundation::Questions;
use App::karr::Foundation::Coordinator;
use App::karr::Foundation::Limits;
# An unknown option or an option value that does not parse exits 2, not 1
# (ADR 0002 exit-code contract). MooX::Options otherwise hands that failure a
# 1, which is the code a genuine runtime failure of a drain carries, and the
# central handler in F<bin/karr-foundation> never sees those exits at all.
with 'App::karr::Role::ExitCodes';
# An option name with a dash in it does not survive standing behind a boolean
# flag -- MooX::Options re-emits the token after a recognised option verbatim,
# so `karr-foundation --verbose --dry-run` reached Getopt::Long under a name
# the specification does not have (ticket #256). F<bin/karr> routes around it
# for every command class; this binary needs the same walk for the same
# reason, and `dry_run` -- the spelling the SYNOPSIS itself uses -- is the
# option it applies to here.
with 'App::karr::Role::CliArgs';
# Instruction handed to a synthesized agent command via the $PROMPT variable
# when neither the .karr file nor the config overrides it.
our $DEFAULT_PROMPT =
'Use the karr-coordinator skill: pick the next actionable task on this '
. 'board, complete it, and move it forward. If you cannot proceed, block '
. 'the task with a reason.';
# The same, for a ticket-mode run. It cannot be $DEFAULT_PROMPT: that one opens
# by telling the agent to pick its own work, which is the one thing a run that
# has already been given a card must not do.
our $DEFAULT_TICKET_PROMPT =
'Use the karr-coordinator skill: work on the one task named below, '
. 'complete it, and move it forward. If you cannot proceed, block the task '
. 'with a reason.';
# Appended to whatever prompt was resolved, ticket id spliced in by foundation
# itself. It has to be foundation that splices: the prompt reaches the agent as
# $PROMPT and /bin/sh does not rescan an expanded value, so a prompt writing
# $KARR_TASK would hand the agent those ten characters (#159). Last, not first,
# because it has to win over an operator prompt that says "pick the next task".
our $TICKET_ASSIGNMENT =
'The task for this run is #%s: work on that one task and no other. Claim '
. 'it before you start, and stop when it is done, handed off or blocked. Do '
. 'not pick up another task.';
option config => (
is => 'ro',
format => 's',
doc => 'Path to config file (default: ~/.config/karr-foundation/config.yml)',
);
option command => (
is => 'ro',
format => 's',
doc => 'Global agent command; overrides .karr file per-repo',
);
option force => (
is => 'ro',
doc => 'Run agent even if no board change detected and no open tasks; '
. 'answer: replace an answer that is already there; plan: replace a '
. 'chain that still has a step running',
);
option dry_run => (
is => 'ro',
doc => 'Print what would run without executing; plan: check a chain and '
. 'write nothing',
);
option verbose => (
is => 'ro',
doc => 'Extra output',
);
option status => (
is => 'ro',
doc => 'Print a read-only overview of every board and exit (no agent runs)',
);
# The question mailbox (#191). These belong to the `ask` and `answer` commands
# and are ignored by a drain, which is why their doc strings say which command
# reads them: this CLI has no per-command option namespace and inventing one
# for two commands would cost more than the prefix in the help text.
option context => (
is => 'ro',
format => 's',
doc => 'ask: prose context for the question',
);
option options => (
is => 'ro',
format => 's',
doc => 'ask: comma-separated answers the question offers',
);
option default => (
is => 'ro',
format => 's',
doc => 'ask: the answer to fall back on (needs --policy use_default)',
lib/App/karr/Foundation.pm view on Meta::CPAN
my ( $self, @argv ) = @_;
user_error( 'Usage: karr-foundation ask QUESTION [--context PROSE] '
. '[--options a,b] [--default a] [--policy block|use_default|escalate_to_ai] '
. '[--wait SECONDS] [--step ID]: quote a question that contains spaces' )
unless @argv == 1 && defined $argv[0] && length $argv[0];
my $mailbox = $self->_mailbox;
$self->_namespace_sync('pull');
my $id = $mailbox->ask(
question => $argv[0],
$self->_option_list( $self->options ),
( defined $self->context ? ( context => $self->context ) : () ),
( defined $self->default ? ( default => $self->default ) : () ),
( defined $self->policy ? ( policy => $self->policy ) : () ),
( defined $self->wait ? ( wait => $self->wait ) : () ),
( defined $self->step ? ( step => $self->step ) : () ),
);
$self->_namespace_sync('push');
# The id and the command that settles it, because the next thing whoever
# reads this does is answer it -- from a terminal that has none of the
# context this one has.
my $q = $mailbox->question($id);
printf "Asked question #%s: %s\n", $id, $q->{question};
printf " answer with: karr-foundation answer %s <%s>\n", $id,
( $q->{options} ? join( '|', @{ $q->{options} } ) : 'answer' );
printf " nobody answers: %s%s\n", $q->{policy},
( defined $q->{deadline} ? " after $q->{deadline}" : '' );
return 0;
}
# The chain (#202). A command of its own, not something an ordinary tick picks
# up on the side: `karr-foundation` with no arguments has meant "drain the
# boards in my config" since it existed, and the day somebody wrote a chain into
# the hub every cron entry in the fleet would silently have started doing
# something else. Opting into execution is the rule agent execution itself
# follows here, and the chain is a bigger opt-in, not a smaller one.
sub _run_chain {
my ( $self, @argv ) = @_;
user_error( 'Usage: karr-foundation chain [--dry-run] [--verbose]'
. ': the chain takes no arguments; what runs is what the plan in '
. 'the hub says is ready' ) if @argv;
my $exit = $self->_executor->run;
# The three chain-side deviations -- a kind: plan step, an escalate_to_ai
# question, a step gone stale -- are recorded by the executor as it meets
# them and answered here, once, after the tick has worked through everything
# it could (#210). The exit code is the executor's either way: whether a
# planner was called says nothing about whether this binary did its job.
$self->_coordinator->dispatch;
return $exit;
}
# ---------------------------------------------------------------------------
# Writing the chain (#213)
# ---------------------------------------------------------------------------
# The chain had no command of its own: App::karr::Foundation::ChainStore's
# write_chain was Perl API, so the one writer that is not a person -- the
# coordination agent -- was handed a `perl -MApp::karr::Foundation::ChainStore
# -e ...` one-liner in its prompt and asked to type it out. That made a storage
# API somebody's interface, where everything else karr asks an agent to do is a
# command, and it meant a rename inside that class broke a prompt rather than a
# call: silently, and only on the tick where a plan was wanted.
#
# It takes a document rather than options because a chain is a DAG and a DAG is
# nested: `--step id=1,kind=ticket,needs=2,3` would be YAML with a worse syntax
# and a parser of its own, and the writer that matters most already produces
# structure. Stdin (or --input) is where `karr restore` takes a snapshot from,
# for the same reason, and the document is read as YAML -- which reads JSON
# too, so an agent that emits JSON has emitted a chain document.
#
# It replaces the chain rather than adding to it, because that is what
# write_chain does and what the header means: ready_steps only considers steps
# whose chain id matches the header, so "append" would be a new chain id over
# the old steps plus the new ones -- a merge with its own rules about ids that
# already exist and states that were already reached. The plan is what the
# planner currently thinks; a chain that still has a running step is refused
# unless --force, which is the guard that makes replacing safe.
sub _run_plan {
my ( $self, @argv ) = @_;
user_error( 'Usage: karr-foundation plan [--input PATH] [--force] '
. '[--dry-run]: the chain itself arrives as YAML or JSON on stdin, or '
. 'from the file --input names' ) if @argv;
# The chain is fleet state, so a machine without a hub has nowhere to put
# one. The same error the mailbox commands raise, for the same reason:
# writing a plan only this clone can see is not a smaller version of writing
# the fleet's plan.
my $store = $self->_chain_store // user_error(
'No usable hub repository: the chain lives in '
. 'refs/karr-foundation/chain/* in the fleet hub, so name one with '
. "'hub: /path/to/repo' in " . $self->_config_path );
# Parsed before the network is touched, so a document that is not one costs
# nothing; the steps themselves are checked by the store, which is where the
# step schema lives and where the write path checks them anyway.
my ( $steps, %header ) = $store->parse_chain_document( $self->_chain_document );
my $force = $self->force ? 1 : 0;
# Before either path: the guard against replacing a chain that still has a
# running step is only as good as this machine's copy of that chain.
$self->_namespace_sync('pull');
if ( $self->dry_run ) {
my $validated = $store->validate_chain( $steps, force => $force );
print 'The chain is valid: ' . scalar(@$validated)
. " step(s), nothing written (--dry-run)\n";
print _plan_step_lines(@$validated);
return 0;
}
my $chain_id = $store->write_chain( $steps, %header, force => $force );
$self->_namespace_sync('push');
# Read back rather than echoed: what is printed is what is in the hub, in
# the order every other reader of the chain sees it.
my @written = $store->steps;
print "Wrote chain $chain_id: " . scalar(@written) . " step(s)\n";
print _plan_step_lines(@written);
print " execute it with: karr-foundation chain\n";
return 0;
}
# The steps as lines: what each one is, where it happens, and what it waits
# for -- the three things somebody reading back a chain they have just written
# checks it against. The ids share a column so the kinds line up under each
# other, which is what makes a mistyped kind visible at a glance.
sub _plan_step_lines {
my ( @steps ) = @_;
my $width = 0;
for my $step ( @steps ) {
my $len = length "$step->{id}";
$width = $len if $len > $width;
}
my @lines;
for my $step ( @steps ) {
my @what = ( defined $step->{ticket}
? "$step->{kind} #$step->{ticket}" : $step->{kind} );
push @what, "in $step->{repo}" if defined $step->{repo};
push @what, 'needs ' . join( ', ', @{ $step->{needs} } )
if $step->{needs} && @{ $step->{needs} };
push @lines, sprintf " %-*s %s\n", $width, $step->{id}, join( ', ', @what );
}
return @lines;
}
# The document as characters, from --input or from stdin.
sub _chain_document {
my ( $self ) = @_;
my $payload;
if ( defined $self->input ) {
# An unreadable --input is the caller's path, not karr's: Path::Tiny's own
# error would hand them this file and line instead (#77).
$payload = try { path( $self->input )->slurp_utf8 }
catch {
user_error( 'Could not read ' . $self->input . ': ' . clean_error($_) );
};
}
else {
# A terminal has nothing queued and would sit there with no prompt, so the
# invocation that forgot its input stays a usage error instead of becoming
# a hang -- the reading App::karr::Cmd::SetRefs makes of the same edge.
user_error( 'Usage: karr-foundation plan < chain.yml: the chain '
. 'document arrives on stdin, or from the file --input names' )
if -t STDIN;
# STDIN is the one input edge App::karr::Encoding leaves without a PerlIO
# layer, precisely so this decode is explicit and happens exactly once.
binmode STDIN, ':raw';
my $octets = do { local $/; <STDIN> };
# An empty stdin is not an empty chain: a generator upstream that produced
# nothing is a mistake, and a chain needs at least one step anyway. A
# runtime failure and not a usage error -- the invocation was right, what
# arrived on the pipe was not.
user_error('No chain document received on stdin')
unless defined $octets && length $octets;
$payload = from_octets($octets);
}
my $doc = try { yaml_load($payload) }
catch {
# Through whole rather than through clean_error, the same way the config
# loader above takes YAML::XS's errors: it names the document, the line and
# the column and carries no call site of its own, and clean_error would
# keep only its "YAML::XS::Load Error: The problem:" header -- which tells
# the writer of a broken chain nothing at all.
user_error("The chain document is not valid YAML or JSON: $_");
};
user_error('The chain document is empty') unless defined $doc;
return $doc;
}
sub _run_answer {
my ( $self, @argv ) = @_;
user_error( 'Usage: karr-foundation answer ID ANSWER [--note TEXT] '
. '[--force]: quote an answer that contains spaces' )
unless @argv == 2 && defined $argv[1] && length $argv[1];
my $mailbox = $self->_mailbox;
$self->_namespace_sync('pull');
my $a = $mailbox->settle( $argv[0], $argv[1],
( defined $self->note ? ( note => $self->note ) : () ),
force => ( $self->force ? 1 : 0 ),
);
$self->_namespace_sync('push');
printf "Answered question #%s: %s\n", $a->{id}, $a->{answer};
printf " %s\n", $a->{question};
return 0;
}
# What each discovered repo is going to do this tick, decided once in the
# parent: whether it is disabled, whether an agent is meant to run on it at
# all, and -- where the command came from a named definition -- which agent,
# because that is the bucket the per-agent concurrency limit counts in. It is
# the same work run() has always done to choose between the overview and a run;
# the concurrent scheduler needs the agent name out of the same resolution, so
# it is computed once and handed on rather than resolved twice.
lib/App/karr/Foundation.pm view on Meta::CPAN
my ( $self, $repo, $karr ) = @_;
my $cfg = $self->_config_data;
for my $candidate ( $self->command, $cfg->{default_command}, $karr->{command} ) {
return ( $candidate, undef ) if defined $candidate && length $candidate;
}
# The assignment (#210), between the board's own `agent:` and the fleet-wide
# `default_agent`. A board that names an agent has said the most specific
# thing there is to say about itself and is not routed; a board that has not
# is exactly what the coordination agent's routing table is for, and that
# table is per repository, so it beats a default that is per fleet.
#
# Three answers, and only the first two are this method's business: an agent
# to run, a reason to wait (returned as the third value -- a board whose
# chain is exhausted or says WAIT runs nothing this tick and is NOT the same
# as a board with no agent configured), or nothing, in which case resolution
# carries on exactly as it did before there was an assignment at all.
my $named = $karr->{agent};
unless ( defined $named && length $named ) {
my $routed = $self->_coordinator->route( $repo );
return ( undef, undef, $routed->{wait} ) if $routed && defined $routed->{wait};
$named = $routed->{agent} if $routed && defined $routed->{agent};
$named //= $cfg->{default_agent};
}
if ( defined $named && length $named ) {
# An unknown name raises: a typo here is not a small mistake. Silently
# falling back to no agent would park the board for good and say nothing,
# which is what `mode:` refuses to do for the same reason. _process_repo
# runs inside run()'s per-repo try, so this warns and skips one board.
my $inv = $self->_agents->invocation( $named );
return ( $inv->{command}, $inv );
}
my $claude = exists $karr->{claude} ? $karr->{claude} : $cfg->{claude};
return ( $self->_claude_command($karr), undef ) if $claude;
return ( undef, undef );
}
# The resolved agent command string, or undef when no agent is configured.
sub _agent_command {
my ( $self, $repo, $karr ) = @_;
my ( $cmd ) = $self->_resolve_agent( $repo, $karr );
return $cmd;
}
# Synthesize the canonical claude invocation behind 'claude: true'. The $PROMPT
# variable is substituted from $ENV{PROMPT} at run time (see _run_command), so
# users never retype the long flag set. claude_bin / claude_max_turns /
# claude_permission_mode override the defaults (per-repo, then global).
sub _claude_command {
my ( $self, $karr ) = @_;
my $cfg = $self->_config_data;
my $bin = $karr->{claude_bin} // $cfg->{claude_bin} // 'claude';
my $turns = $karr->{claude_max_turns} // $cfg->{claude_max_turns} // 30;
my $perm = $karr->{claude_permission_mode} // $cfg->{claude_permission_mode} // 'bypassPermissions';
return qq{$bin -p "\$PROMPT" --permission-mode $perm --max-turns $turns};
}
# The agent instruction exposed as $PROMPT. .karr 'prompt' > config
# 'default_prompt' > the built-in default.
#
# With a $ticket the built-in default changes (the ordinary one opens by
# telling the agent to pick its own work) and the assignment sentence is
# appended to whatever prompt was resolved. Appending rather than replacing
# keeps a configured prompt doing its job â it is usually about which skill to
# use and how to report â while the last sentence, which is the one that wins
# with a language model, is the one naming the card. Without this the mode
# would be `drain: false` with extra steps: the agent would never learn which
# ticket it was given.
sub _prompt_for {
my ( $self, $karr, $ticket ) = @_;
my $configured = $karr->{prompt} // $self->_config_data->{default_prompt};
return $configured // $DEFAULT_PROMPT unless defined $ticket;
return ( $configured // $DEFAULT_TICKET_PROMPT ) . "\n\n"
. sprintf( $TICKET_ASSIGNMENT, $ticket );
}
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
App::karr::Foundation - Single-shot foundation daemon -- periodic agent execution across karr boards
=head1 VERSION
version 0.600
=head1 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
=head1 DESCRIPTION
F<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 B<drains> each board by invoking the
configured agent command repeatedly until no actionable task remains.
B<Using this class as a library.> F<bin/karr-foundation> is what most callers
run, and it is also where karr's character/octet boundary gets set up (see
L<App::karr::Encoding>) before any command code runs: a C<:encoding(UTF-8)>
layer goes on C<STDOUT>/C<STDERR>, and C<@ARGV> is decoded before
C<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 L<App::karr::Encoding/enable_std_utf8> and
L<App::karr::Encoding/decode_argv>). A caller that loads
C<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 C<skip $repo -- $wait> line, or a YAML
error carried through C<clean_error> into a C<warn> -- which still risks
C<Wide character in print>/C<warn> the first time it reaches a handle nobody
configured. Skipping C<@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.
B<Config file:> C<~/.config/karr-foundation/config.yml> (or C<--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.
B<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
C<claude>, C<claude_bin>, C<claude_max_turns>, C<claude_permission_mode>,
C<command>, C<mode>, C<on_drained>, C<on_drained_max_runtime>,
C<on_drained_max_rounds> and C<prompt>/C<default_prompt> may also be set
globally in the config file; the per-repo F<.karr> value wins.
B<Named agents.> A board has one C<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
C<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. C<karr-foundation --status --verbose> prints it.
Agent definitions are B<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.
C<agent:> resolves below the literal command strings and above C<< claude:
true >> -- the full order is C<--command>, C<default_command>, the F<.karr>
C<command>, the F<.karr> C<agent>, the B<assignment> (see "The coordination
agent" below), C<default_agent>, C<< claude: true >>. A board that names an
agent the config does not define is an error that skips B<that board>, not one
that silently stops running.
B<Invocation contracts.> C<kind> says what karr may append to a definition's
C<command>:
=over 4
=item * C<shell> (the default) - the command is a complete shell template and
karr appends B<nothing>. This is what a F<.karr> C<command> has always been:
karr cannot know what the thing at the other end understands.
=item * C<claude-code> - karr appends C<-p "$PROMPT">, an output format, and
C<--permission-mode>, C<--max-turns> and C<--allowed-tools> from the
definition. Permission escalation is therefore a property of the agent
definition rather than something baked into a wrapper script.
=back
The output format is C<stream-json --verbose --include-partial-messages>, not
plain C<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
lib/App/karr/Foundation.pm view on Meta::CPAN
It is also a command rather than something an ordinary tick does on the side:
C<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 C<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 C<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
L<App::karr::Foundation::Executor>.
B<Writing the chain.> C<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
C<--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
L<App::karr::Foundation::ChainStore/write_chain>'s own first argument looks
like, so a planner that wrote only steps wrote a whole document.
It B<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 C<running>
is refused unless C<--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
(L<App::karr::Foundation::ChainStore/validate_chain>).
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 C<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).
B<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". C<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 C<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"
C<--policy> is what happens when nobody answers: C<block> (the default: wait),
C<use_default> (C<--default> becomes the answer once C<--wait> has passed) or
C<escalate_to_ai> (the coordination agent decides). Both commands sync the fleet
namespace around what they write, and C<--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
L<App::karr::Foundation::Questions>.
B<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
B<judgement> -- planning, routing, reacting to what nobody planned for -- is an
agent. It is an agent like every other one: an entry in C<agents:>, invoked
through its own C<command> under its own C<kind> contract, classified from its
own result object, and marked C<failing> by the same availability record. What
sets it apart is B<when> it runs, which is never in the hot path.
F<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; C<< 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 C<kind: plan> step, a
question past its deadline whose policy is C<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 B<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 B<next> tick runs.
The run happens in the hub, under the hub's own F<.karr.lock> (one agent per
lib/App/karr/Foundation.pm view on Meta::CPAN
meant "one run" would be a trap, so they are one key with an alias rather than
two switches -- C<mode> is asked first, C<drain> answers only when C<mode> is
absent, and a per-repo C<drain> still beats a config-wide C<mode>. An
unrecognised C<mode> is an error that skips the repo, never a silent fallback
to draining it.
B<Ticket mode.> Before the agent starts, foundation picks the card the run is
about -- L<App::karr::Foundation::Picker>, applying C<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 C<$PROMPT> as a
closing sentence naming the id, and exported as C<$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 (C<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 B<not> claim it. The claim is the agent's
work session, minted with C<karr agentname> and reused across its own C<move>
and C<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
C<claim_timeout>, or by C<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: C<progress> when
it moved (status, claim or C<updated> changed, or it left the actionable set),
C<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 C<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
B<no agent>, logs C<TICKET none assignable>, and returns C<idle>; C<--force> and
C<< on_idle: always-run >> force the check, not a run without a card.
B<Board-level disable.> A board can opt out of automated agent runs in its own
karr state -- C<foundation.enabled> in C<refs/karr/config>, set with
C<karr disable [--reason "why"]> and cleared with C<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 B<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
C<--command>, the config's C<default_command>, the F<.karr> C<command> and
C<< claude: true >>, and C<--force> does B<not> override it. Use it for a
repository whose backlog is parked (an abandoned project kept for reference)
that a globally configured C<default_command> would otherwise drain. C<--status>
shows such a board with a C<disabled> flag and its reason.
B<The domain hook.> When a board has drained, C<on_drained> runs a configured
command in it. B<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 F<.karr.log> and F<.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 C<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: C<KARR_REPO>, and C<KARR_ROLE=hook> so
that C<karr> writes of its own land in their own activity log rather than
counting as the agent's engagement with a card. C<PROMPT> is empty (the prompt
is the agent's instruction) and so is C<KARR_TASK>. It runs in the board's
directory, under the board's own F<.karr.lock>, with the same process-group
kill and the same tee to F<.karr.log> an agent gets -- a gate that backgrounds
a build must not outlive the run that started it -- but with its own budget,
C<on_drained_max_runtime>, because how long an agent may take says nothing
about how long a release gate may.
B<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 C<--force> and C<< 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 C<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.
B<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:
=over 4
=item * B<The same board is not asked twice.> The board fingerprint the hook
last ran at is kept in F<.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.
=item * B<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 C<on_drained_max_rounds> (default 3, C<0> disables) the hook is
suppressed with a line in F<.karr.log> saying so.
=back
C<--force> overrides both. They are statements about board state, which is what
C<--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.
B<Coordinator and overview.> Agent execution is opt-in -- a board runs an agent
only via C<command>, a named C<agent> or C<< claude: true >>. When B<no> board
has an agent configured, the default action is a read-only B<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. C<--status> forces the overview regardless
of configuration.
B<Live output.> When run interactively (TTY) or with C<--verbose>, the agent's
output is streamed to the terminal in real time as foundation reads it; it is
always appended to F<.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
( run in 0.474 second using v1.01-cache-2.11-cpan-aadc1410aed )