App-karr

 view release on metacpan or  search on metacpan

.claude/skills/karr-foundation-cli/SKILL.md  view on Meta::CPAN

| **common-error** | bad exit, timeout, or an error pattern in a run that moved *nothing* | exponential backoff, no task penalty |
| **idle** | agent did nothing, grabbed nothing | stop |

**What a run did is asked before what it printed.** A run that exited 0 and
moved the board is progress whatever scrolled past it, and is never
reclassified by its own transcript; the output is scanned only for a run that
moved nothing 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 narrow to match: a
symptom word counts next to a failure word on the same line (`network error`,
`invalid credentials`, `quota exceeded`), and an HTTP status only where
something adjacent marks it as one (`API error: 429`, `429 Too Many Requests`)
— not in a diffstat, a byte count or a line number. Before that, an agent
printing its own board tripped the scan on a backlog title and throttled a
healthy board to one run per hour (#160).

### Auto-block

When a task is stuck after `max_attempts`, foundation marks it blocked with:
```
blocked: auto-block: no progress after N attempts (foundation)

.gitignore  view on Meta::CPAN

# Claude Code — commit: skills/, agents/, hooks/, settings.json
# Ignore: local overrides, credentials, session data

# Tracked: shared config & extensibility
!/.claude/
.claude/*
!.claude/settings.json
!.claude/agents/
!.claude/agents/**
!.claude/skills/
!.claude/skills/**
!.claude/rules/
!.claude/rules/**
!.claude/hooks/
!.claude/hooks/**

# Local overrides (machine-specific)
.claude/*.local.*
.claude/local/

# Credentials & session state (never track)
.claude/.credentials.json
.claude/statsig/
.claude/todos/
.claude/projects/

# AGENTS.md ignored - our-codex
AGENTS.md

# karr materialized task view — never commit
tasks/
config.yml

Changes  view on Meta::CPAN

      and refuses the status change, matching kanban-md's `validateEditPost`
      firing after `applyFn` regardless of release
      (`internal/board/mutate.go:442`). `--release` alone on a card already
      in a require_claim column is intentionally left unchanged: it is the
      same shape but outside the ticket's scope (kanban-md has the same
      hole).

    - karr-foundation no longer throws away a successful agent run because of
      something it printed (ticket k160). The common-error scan ran on every
      run before anything asked whether the run had worked, over the whole
      transcript, against bare substrings — network, quota, credentials, 401,
      403, 429, 503. An agent working a karr board prints the board, so a
      backlog line reading "retry the network fetch on 503" matched, and so
      did a diffstat of 403 changed lines. The drain aborted, the cards the
      agent had just moved were credited to nobody, and the cooldown climbed
      1m, 2m, 4m … 64m without ever resetting, because the next run printed
      the same words: a healthy board throttled to one discarded run per hour.
      What a run did is now asked before what it printed. A run that exited 0
      and moved the board is progress whatever scrolled past, and is never
      reclassified by its own output; the scan is evidence only where there is
      none other, a run that moved nothing — 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 narrow to match: a symptom word counts next to a failure
      word on the same line ("network error", "invalid credentials", "quota
      exceeded"), never on its own, and an HTTP status only where something
      adjacent marks it as one ("API error: 429", "429 Too Many Requests") —
      not in a diffstat, a byte count, a line number or a commit hash. Genuine
      failures reported by an agent that still exits 0 keep triggering the
      backoff, which is what the scan is for. A board's own `error_patterns`
      are unchanged: plain case-insensitive substrings.

    - `.karr.state` no longer keeps a `last_error` from a run three cooldowns
      ago sitting next to `last_exit: 0` with nothing to explain the pair
      (ticket k160). `last_error` describes the last run and is dropped by the

ex/README.md  view on Meta::CPAN

clone`, so the boards are built at run time by `setup.sh`. What *is* committed
is this README, `setup.sh`, `bin/`, `scripts/` and the article below.

## Prerequisites

- Perl (5.20+) with the dependencies of this checkout installed. From the
  repository root, `cpanm --installdeps .` or whatever your
  [Dist::Zilla](https://dzil.org) setup prefers; running `prove -l t/` once
  from the root is a good sanity check.
- `git` on `PATH`.
- Nothing else: no server, no network, no credentials.

## Build the sandbox

```bash
./ex/setup.sh           # create (idempotent: existing pieces are left alone)
./ex/setup.sh --reset   # wipe the generated state and rebuild from scratch
```

The script creates `ex/webapp/` and `ex/docs-site/` (each a git repo with a
karr board seeded with sample cards and a `.karr` file), `ex/fleet-hub/` (the

ex/README.md  view on Meta::CPAN


The order of evidence is intentional: **what the run did is asked before what it
printed.** A run that ended with 0 and moved the board is progress, no matter
what text scrolled by, and is never reclassified by its own transcript. The text
scan is evidence only where there is no other — for a run that produced no board
movement at all, which looks exactly like a rate-limited or unauthenticated
agent. A pattern in a run that moved the board **after all** is noted in
`.karr.log` and otherwise ignored.

Correspondingly tight are the default patterns: a symptom word only counts next
to an error word in the same line ("network error", "invalid credentials",
"quota exceeded"), not on its own, and an HTTP status only counts where
something next to it identifies it as such ("API error: 429", "429 Too Many
Requests") — not in a diffstat and not in a line number. Before that, an agent
that printed its own board triggered the scan on a backlog title, and a diffstat
with 403 changed lines on the `403`. `error_patterns:` in the `.karr` adds your
own case-insensitive substrings.

**The run's own report.** An agent invoked with `--output-format json` ends its
output with one line: a JSON object that says whether the run failed, how it
ended, how many turns it needed, how long it ran and what it cost. Where there

lib/App/karr/Foundation.pm  view on Meta::CPAN

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 B<did> move the
board is noted in F<.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 C<403> (#160).

=item * B<idle> -- the agent did nothing and grabbed nothing; stop.

=back

lib/App/karr/Foundation/Runner.pm  view on Meta::CPAN


# ---------------------------------------------------------------------------
# Common-error detection
# ---------------------------------------------------------------------------

# What the drain scans an agent's transcript for: a failure the agent reports
# while still exiting 0 -- a rate limit, a dead key, a 5xx -- because that run
# produced nothing and starting the next one immediately just spends the next
# window on the same wall.
#
# These were bare case-insensitive substrings (network, quota, credentials,
# 401, 403, 429, 503, ...) matched against the whole transcript. That is not a
# near-miss instrument, it is a word search over everything the agent printed,
# and an agent working a karr board prints the board: a backlog line reading
# "retry the network fetch on 503" tripped it twice over, and "403" tripped on
# a diffstat (#160). So a symptom word on its own never counts here. It counts
# next to a failure word on the same line ($SIGNAL / _near), or inside one of
# the fixed phrases an API really emits. Numbers are the worse half -- 403 is a
# line count, a byte count, a task id -- so an HTTP status counts only where
# something adjacent says it is one (_http).
#

lib/App/karr/Foundation/Runner.pm  view on Meta::CPAN

  [ 'too many requests', ['too many requests'], qr/\btoo many requests\b/i ],
  [ '429', ['429'],                        _http( 429, qr/too many requests/i ) ],
  [ '529', ['529'],                        _http( 529, qr/overloaded/i ) ],
  # authentication
  [ 'invalid api key', ['api'],
    qr/\b(?:invalid|missing|expired|revoked|no)\s+api[_ -]?key\b
     | \bapi[_ -]?key\b [^\n]{0,24}?
       \b(?:invalid|missing|expired|revoked|required|not\s+found)\b/xi ],
  [ 'authentication', ['authentication_error'], qr/\bauthentication_error\b/i ],
  [ 'authentication', ['authenticat'],     _near( qr/\bauthenticat(?:ion|ed|e)\b/i ) ],
  [ 'credentials', ['credential'],         _near( qr/\bcredentials?\b/i ) ],
  [ 'unauthorized', ['unauthori'],         _near( qr/\bunauthori[sz]ed\b/i ) ],
  [ 'forbidden', ['forbidden'],            _near( qr/\bforbidden\b/i ) ],
  [ '401', ['401'],                        _http( 401, qr/unauthori[sz]ed/i ) ],
  [ '403', ['403'],                        _http( 403, qr/forbidden/i ) ],
  # network / transport
  [ 'network', ['network'],                _near( qr/\bnetwork\b/i ) ],
  [ 'connection', ['connection'],
    qr/\bconnection\s+(?:refused|reset|closed|aborted|error|failed)\b/i ],
  [ 'connection',
    [qw( econnrefused econnreset etimedout ehostunreach enetunreach enotfound eai_again )],

lib/App/karr/Git.pm  view on Meta::CPAN

# class, rather than the "do not ask at all" it used to mean when the CLI was
# the only route.
#
# Nothing here may prompt: this call is not one the user made, so a passphrase
# prompt appearing in the middle of `karr list --json` would be a surprise
# that only the deadline ends. On the CLI side GIT_TERMINAL_PROMPT=0 (set by
# _run_git) covers git's own credential prompts; BatchMode covers ssh's, which
# git never sees. It is appended to the user's own GIT_SSH_COMMAND rather than
# replacing it, so a configured wrapper still runs -- and ssh takes the first
# value it is given for an option, so an explicit BatchMode of theirs still
# wins. The native side runs no ssh binary at all: _default_credentials_cb
# hands libgit2 an agent, a key file with an explicit (empty) passphrase, or
# nothing, and never an interactive credential -- so a passphrase-protected
# key with no agent behind it fails the connection rather than asking anyone.
sub remote_has_board {
    my ( $self, $remote ) = @_;
    $remote //= 'origin';
    return 0 unless $self->has_remote($remote);

    my $budget   = _probe_timeout();
    my $share    = $ENV{KARR_NO_CLI_FALLBACK} ? $budget : $budget / 2;

lib/App/karr/Git.pm  view on Meta::CPAN

    # reach it through the same one.
    local $ENV{GIT_SSH_COMMAND} =
        ( $ENV{GIT_SSH_COMMAND} || 'ssh' ) . ' -o BatchMode=yes';

    my ( $names, $native_why );
    if ( my $repo = $self->_repo ) {
        # _repo has just applied the transport budget; the probe's is smaller.
        _set_native_transport_timeouts($share);
        try {
            $names = $repo->remote($remote)
                ->list_refs( credentials => _default_credentials_cb() );
        } catch {
            $native_why = clean_error($_);
        };
        _apply_native_transport_timeouts();
    }

    # list_refs answers with the remote's own ref names, HEAD included and no
    # refspec mapping applied, so the board is a prefix match. An answer with
    # no board ref in it is an answer: the remote has none.
    return ( grep { index( $_, BOARD_ROOT ) == 0 } @$names ) ? 1 : 0

lib/App/karr/Git.pm  view on Meta::CPAN

            : $run->{failure} eq 'timeout' ? "no answer within ${budget}s"
            : length $detail               ? $detail
            :                                "git ls-remote exited " . ( $run->{status} >> 8 );
    }

    $self->{_last_error} = $why // 'the remote could not be asked';
    return undef;
}


# Default credentials callback: SSH-agent → ~/.ssh/id_ed25519 → ~/.ssh/id_rsa
# → default → fail. Matches CLI `git`'s implicit auth chain.
sub _default_credentials_cb {
    my @tried;
    return sub {
        my (%args) = @_;
        my $user  = $args{username_from_url} || 'git';
        my $types = $args{allowed_types}    || 0;

        # GIT_CREDENTIAL_SSH_KEY = 1<<1 = 2
        if ( $types & 2 ) {
            return Git::Native::Credential->ssh_agent( username => $user )
                unless $tried[0]++;

lib/App/karr/Git.pm  view on Meta::CPAN


    my $result;
    my $ok = try {
        my $r = $repo->remote($remote);
        if ( my $why = _misdirected_local_push($r) ) {
            $self->{_last_error} = $why;
            return $self->_cli_transport( 'push', $remote, $refspecs );
        }
        $result = $r->push(
            refspecs    => $refspecs,
            credentials => _default_credentials_cb(),
        );
        1;
    } catch {
        $self->{_last_error} = "$_";
        $self->_cli_transport( 'push', $remote, $refspecs );
    };
    return 0 unless $ok;
    return $self->_accept_push_result( $remote, $result ) ? 1 : 0;
}

# The fetch half of the same: native first, CLI fallback, no reconciliation.
# An empty @$refspecs means the remote's configured ones.
sub _fetch_refspecs {
    my ( $self, $remote, $refspecs, %opt ) = @_;
    my $repo = $self->_repo or return 0;
    return try {
        my $r = $repo->remote($remote);
        $r->fetch(
            refspecs    => $refspecs,
            credentials => _default_credentials_cb(),
            ( $opt{prune} ? ( prune => 1 ) : () ),
        );
        1;
    } catch {
        $self->{_last_error} = "$_";
        $self->_cli_transport( 'fetch', $remote, $refspecs, %opt );
    };
}

sub push {

lib/App/karr/Git.pm  view on Meta::CPAN


    $git->pull;
    my @ids = $git->list_task_refs;
    my $task = $git->load_task_ref($ids[0]);

=head1 DESCRIPTION

L<App::karr::Git> provides the low-level Git interface used by C<karr> for
syncing board state through C<refs/karr/*>. Local object/ref ops (read/write/
delete of refs, blobs, trees, commits) run natively via L<Git::Native> (FFI
to libgit2) with no fork/exec. SSH-agent and HTTPS-token credentials are
supplied through the libgit2 credential-acquire callback.

Network fetch/push (C<fetch>, C<pull>, C<push>, C<push_ref>, C<pull_ref>)
also try the native libgit2 transport first. If that transport fails, they
fall back to the system C<git> CLI (via L<IPC::Open3>). What the fallback is
there for is C<~/.ssh/config>, which libgit2 does not read: a C<Host> alias,
the C<IdentityFile>, C<User> or C<Port> written under it, and a
C<ProxyCommand> all take effect through the CLI and nowhere else. libssh2
reads a remote spelled C<board:karr.git> as the literal host C<board> and
stops at the name lookup, so for a board reached through an alias the CLI is

t/152-foundation-error-scan-ordering.t  view on Meta::CPAN

use Test::More;
use Path::Tiny qw( path tempdir );

use App::karr::Foundation;
use App::karr::Git;
use App::karr::BoardStore;
use App::karr::Task;
use App::karr::Encoding qw( json_decode );

# Ticket #160: foundation scanned an agent's whole transcript for bare
# substrings -- network, quota, credentials, 401, 403, 429, 503 -- before it
# looked at what the run had done. An agent working a karr board prints the
# board, so a backlog line reading "retry the network fetch on 503" and a
# diffstat of 403 changed lines both classified a run that exited 0 and moved
# a card as a common error: the drain aborted, the progress was credited to
# nobody, and the cooldown climbed 1m, 2m, 4m ... 64m without ever resetting,
# because the next run printed the same words.
#
# Two rules come out of that, and this file holds both halves of each: what the
# run DID outranks what it PRINTED (a run that moved the board is never
# reclassified by its own output), and a symptom word only counts next to a

t/152-foundation-error-scan-ordering.t  view on Meta::CPAN


  my @mundane = (
    [ 'a backlog line about networking'    =>
      "#1 backlog retry the network fetch on 503\n#2 backlog tidy the parser\n" ],
    [ 'a diffstat with 403 changed lines'  =>
      " lib/App/karr/Foundation.pm | 403 ++++++++-----\n"
      . " 1 file changed, 403 insertions(+), 261 deletions(-)\n" ],
    [ 'a commit hash'                      => "commit 4291d403b529f401e503bc0e\n" ],
    [ 'a source line number'               => "at lib/App/karr/Foundation.pm line 403.\n" ],
    [ 'a ticket about rate limiting'       => "#12 todo add rate limiting to the api client\n" ],
    [ 'a ticket about credentials'         => "#13 todo document the credentials file format\n" ],
    [ 'a ticket about quotas'              => "#15 backlog quota accounting for the runner\n" ],
    [ 'byte and millisecond counts'        => "read 429 bytes in 503ms\n" ],
    [ 'a ref path holding a task id'       => "wrote refs/karr/tasks/403/data\n" ],
    [ 'an ordinary success line'           => "all tests passed\n" ],
  );

  for my $case ( @mundane ) {
    my ( $what, $text ) = @$case;
    is $f->_match_error( $text, $pat ), undef, "not an error: $what";
  }

t/152-foundation-error-scan-ordering.t  view on Meta::CPAN

# ---------------------------------------------------------------------------

my @GENUINE = (
    [ 'rate limit'            =>
      'API Error: 429 {"type":"error","error":{"type":"rate_limit_error"}}' ],
    [ 'rate limit'            => 'Error: rate limit exceeded, retry later' ],
    [ 'usage limit'           => 'Claude AI usage limit reached|1786560000' ],
    [ 'authentication'        =>
      'API Error: 401 {"type":"error","error":{"type":"authentication_error"}}' ],
    [ 'invalid api key'       => 'Invalid API key - Please run /login' ],
    [ 'credentials'           => 'invalid credentials for the configured account' ],
    [ '403'                   => 'Request failed with status code 403' ],
    [ 'too many requests'     => '429 Too Many Requests' ],
    [ 'service unavailable'   => 'HTTP/1.1 503 Service Unavailable' ],
    [ 'overloaded'            =>
      'API Error: 529 {"type":"error","error":{"type":"overloaded_error"}}' ],
    [ 'quota'                 => 'your quota has been exceeded, try again tomorrow' ],
    [ 'network'               => 'Error: network error while contacting the API' ],
    [ 'connection'            => 'connection reset by peer' ],
    [ 'connection'            => 'connect ECONNREFUSED 127.0.0.1:443' ],
    [ 'name resolution'       => 'could not resolve host: api.anthropic.invalid' ],

t/187-foundation-result-json.t  view on Meta::CPAN

    result           => 'done',
    %over,
  } );
}

# What #160's corpus proves an ordinary agent prints: its own board, including
# a backlog title with a symptom word in it, and a genuine-looking API error
# line. Neither may reach the classifier once the run has reported for itself.
my $NOISE = <<'NOISE';
#3 backlog retry the network fetch on 503
#4 backlog invalid credentials in the auth path
API Error: 429 {"type":"error","error":{"type":"rate_limit_error"}}
NOISE

# A harmless fake agent: it prints what the mode tells it to, optionally moves
# one card through karr's own store, and exits. It never leaves the temp repo
# and it never calls anything real.
sub write_fake_agent {
  my ( $dir ) = @_;
  my $lib    = path('lib')->absolute->stringify;
  my $script = path($dir)->child('fake-agent.pl');



( run in 1.778 second using v1.01-cache-2.11-cpan-007c89162af )