App-karr

 view release on metacpan or  search on metacpan

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


### Edit task

```bash
karr edit ID --title "New title"
karr edit ID --priority high --add-tag urgent
karr edit ID --body "New description"
karr edit ID -a "Appended note"              # append to body
karr edit ID --claim agent-1                 # claim
karr edit ID --release                       # release claim
karr edit ID --block "Waiting on API"        # mark blocked
karr edit ID --unblock                       # clear blocked
```

### Delete task

```bash
karr delete ID --yes                         # skip confirmation
```

### Archive task

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

Shows tasks grouped by status with WIP utilization.

### Pick next task (multi-agent)

```bash
karr pick --claim agent-1                    # pick highest priority available
karr pick --claim agent-1 --status todo --move in-progress
karr pick --claim agent-1 --tags backend
```

Atomically finds and claims the next available task. Respects claim timeouts, blocked state, and class-of-service priority ordering (expedite > fixed-date > standard > intangible).

### Handoff task for review

```bash
karr handoff ID --claim agent-1              # move to review, refresh claim
karr handoff ID --claim agent-1 --note "Done, needs QA" --timestamp
karr handoff ID --claim agent-1 --block "waiting for feedback" --release
```

Moves task to `review`, refreshes claim, optionally appends a timestamped note, blocks, or releases the claim.

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

karr config --json                           # JSON output
```

Writable keys: `board.name`, `board.description`, `defaults.status`, `defaults.priority`, `defaults.class`, `claim_timeout`.

### Context (board summary for embedding)

```bash
karr context                                 # print markdown summary
karr context --write-to AGENTS.md            # create/update file with sentinels
karr context --sections blocked,overdue      # filter sections
karr context --days 14                       # lookback for recently-completed
karr context --json                          # JSON output
```

Generates a markdown summary with sections: In Progress, Blocked, Overdue, Recently Completed. Uses `<!-- BEGIN kanban-md context -->` / `<!-- END kanban-md context -->` sentinels for in-place updates.

### Skill management

```bash
karr skill install                           # install skill for detected agents
karr skill install --agent claude-code       # install for specific agent
karr skill install --global                  # install globally (~/)
karr skill install --force                   # force reinstall
karr skill check                             # check if installed skills are current
karr skill update                            # update outdated skills

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

is kept separately in `refs/karr/meta/next-id`.

## Decision tree: which command?

1. **Need a board?** → `karr init`
2. **New work item?** → `karr create "Title" --priority high`
3. **What's on the board?** → `karr board` or `karr list`
4. **Starting work?** → `karr pick --claim NAME --move in-progress`
5. **Done with task, hand to review?** → `karr handoff ID --claim NAME --note "reason"`
6. **Done with task, close it?** → `karr edit ID --release && karr move ID done`
7. **Blocked?** → `karr edit ID --block "reason"`
8. **Need details?** → `karr show ID`
9. **Soft-delete?** → `karr archive ID`
10. **Board snapshot for agent context?** → `karr context --write-to AGENTS.md`
11. **Check/change config?** → `karr config` / `karr config set KEY VALUE`
12. **Install agent skills?** → `karr skill install`
13. **Need a full board snapshot?** → `karr backup` / `karr restore --yes`
14. **Need shared non-task workflow data?** → `karr set-refs` / `karr get-refs`

## Multi-agent workflow

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

  - my custom api error
```

`claude`, `claude_bin`, the `claude_*` knobs, `command` and `prompt` may also be
set globally in `config.yml` (`default_command` / `default_prompt`); the per-repo
`.karr` value wins.

## Overview

`karr-foundation --status` (and the default when no board has an agent) prints a
read-only dashboard of every board: status counts, in-progress/blocked tasks,
and lock/cooldown state. No agent is run — usable by a human to coordinate work.

## Options

```bash
karr-foundation --config PATH       # custom config file
karr-foundation --force             # run even if no board change / open tasks
karr-foundation --dry-run --verbose # preview without executing
karr-foundation --status            # read-only overview of every board, no runs
```

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


| Outcome | Meaning | Action |
|---------|---------|--------|
| **progress** | board changed | keep draining |
| **stall** | task claimed but didn't move | bump attempt counter; auto-block after `max_attempts` |
| **common-error** | bad exit, timeout, or error pattern | exponential backoff, no task penalty |
| **idle** | agent did nothing, grabbed nothing | stop |

### Auto-block

When a task is stuck after `max_attempts`, foundation marks it blocked with:
```
blocked: auto-block: no progress after N attempts (foundation)
```
Agent can override with `karr edit --block "reason"`.

### Exponential cooldown

On common-error: repo waits `cooldown_base × 2^level` minutes (capped at `cooldown_max`).
Level resets on next clean (non-error) run.

## State files (gitignored)

CONTEXT.md  view on Meta::CPAN

**Foundation**:
The multi-board coordinator (`karr-foundation`) that sweeps several boards in
sub-directories. It serves two first-class users: a **HUMAN** coordinating their
own work, and an **agent** (role `agent`) driving tasks. Agent execution is
opt-in (`claude: true` or an explicit `command:`); with no agent configured its
default action is the read-only **Overview**.

**Overview**:
Foundation's read-only dashboard (`--status` / `--overview`, or the default when
no agent is configured) — per board: status counts and what is
in-progress/claimed/blocked, plus which repos are locked (agent running) or in
cooldown. Fires no agent.

**Claim name**:
The ephemeral two-word agentname (e.g. `agent-fox`) passed per `pick`/`move`
via `--claim`, stored in `claimed_by` and in the **Activity log** entry's
`agent` field. Distinct from **Identity**: a single Identity may run under many
Claim names over time.

## Relationships

Changes  view on Meta::CPAN

0.302     2026-06-21 23:04:42Z

    - `karr board` now renders a compact, Markdown-flavoured plaintext board
      (board name as `#`, each status as `## Section`, one
      `- id | title | meta...` line per task) instead of the coloured column
      dashboard. The output stays clean when piped or redirected — colour is
      added only when stdout is a terminal and `NO_COLOR` is unset. Default
      (`medium`) priority is suppressed, and a new `--tags` flag prints each
      task's tags on an extra indented line.
    - Fix releasing a claim or unblocking a task leaving a null `claimed_by`,
      `claimed_at`, or `blocked` field behind. Clearing now uses real Moo
      clearers so the predicate drops and the field is omitted from the task
      file, instead of being written as an explicit null that reloaded as
      "still set" — which made `handoff` reject released tasks and `pick`
      treat them as claimed. Explicit nulls in already-written or external
      task files are normalized to "unset" on load.

0.301     2026-06-04 22:35:33Z

    - karr-foundation: stream agent output to the terminal when interactive
      (TTY detected) or --verbose is set. The parent process now reads the
      child's output through a native pipe and fans it to the log, the
      terminal, and an in-memory buffer — no external `tee` and no re-reading
      the log by byte offset. The per-run timeout is `select`-based (robust
      against Perl's deferred signals) and only fires when max_runtime > 0
      (max_runtime: 0 disables it entirely). Output is always appended to
      .karr.log regardless of TTY.
    - karr-foundation is now a multi-board coordinator, not just an agent
      runner. Agent execution is opt-in: with no agent configured on any
      board, the default action is a read-only overview of every board
      (status counts, in-progress/blocked, lock/cooldown state). `--status`
      forces that overview regardless of configuration.
    - karr-foundation: `claude: true` synthesizes the canonical claude
      invocation so you needn't retype it; `claude_bin`, `claude_max_turns`
      and `claude_permission_mode` override the parts. The agent instruction
      is exposed as the `$PROMPT` substitution variable (settable via `prompt`
      in .karr or `default_prompt` in config), usable in any command template.
    - Activity log entries are now keyed by a role-qualified identity
      (`refs/karr/log/<role>/<email>`, role `user` or `agent`) so a human and
      an AI sharing one Git config are told apart. The role propagates to
      nested karr calls via the KARR_ROLE env var (foundation sets `agent`);

Changes  view on Meta::CPAN

    - Git.pm: read git config (user.name/email) and validate helper ref
      names through Git::Native (Config + reference_name_is_valid) instead
      of poking Git::Libgit2::FFI directly. New Git.pm `ref_oids` helper.
    - karr-foundation: detect board changes via Git::Native instead of
      shelling out to `git for-each-ref` — no git binary needed for that
      path anymore. Sync (`--pull`) and open-task detection now run
      in-process via App::karr::Git/BoardStore instead of forking the
      `karr` CLI.
    - karr-foundation: drain each board instead of a single run — invoke
      the agent command repeatedly until no actionable task (non-terminal
      and unblocked) remains. A task the agent claims but never moves is
      auto-blocked after `max_attempts` stalls (default 2) so the drain
      always terminates; the agent's own `--block` reason still wins.
      Observable common errors (non-zero/timeout exit, or a log match
      against rate-limit/auth/network/5xx patterns, extensible via
      `error_patterns`) never penalize a task and instead trigger an
      exponential per-repo cooldown (1, 2, 4, … minutes, capped). New
      `.karr` keys: `drain`, `max_attempts`, `max_iterations`,
      `cooldown_base`, `cooldown_max`, `error_patterns`.
    - cpanfile: require Git::Native 0.003 and Git::Libgit2 0.004.
    - Fix `karr context` / `karr context --json` crashing with
      "Can't locate object method 'strftime' via package 'Sun May ...'":

bin/karr  view on Meta::CPAN

=item * L<App::karr::Cmd::List>

Lists tasks with filters for status, priority, tags, claims, and text search.

=item * L<App::karr::Cmd::Show>

Shows the full task document for one task id.

=item * L<App::karr::Cmd::Edit>

Updates task metadata, body text, claims, and blocked state.

=item * L<App::karr::Cmd::Move>

Moves tasks between statuses, including relative moves with C<--next> and
C<--prev>.

=item * L<App::karr::Cmd::Delete>

Deletes task refs permanently.

docs/superpowers/plans/2026-03-15-karr-git-sync.md  view on Meta::CPAN

    return "refs/karr/tasks/" . $self->task_id . "/lock";
}

sub can_acquire {
    my ($self, $agent) = @_;
    my $repo = Git::Raw::Repository->open($self->board_dir->stringify);
    my $ref = $self->ref_path;
    my $current = eval { $repo->reference($ref) };
    return 1 unless $current;
    my $content = $current->target->content;
    chomp(my $locked_by = $content);
    return $locked_by eq '' || $locked_by eq $agent;
}

sub acquire {
    my ($self, $agent) = @_;
    return 0 unless $self->can_acquire($agent);
    my $repo = Git::Raw::Repository->open($self->board_dir->stringify);
    my $ref = $self->ref_path;
    my $blob = $repo->blob($agent);
    my $commit = eval { $repo->reference($ref) };
    # Create or update ref

docs/superpowers/plans/2026-03-15-karr-git-sync.md  view on Meta::CPAN


- [ ] **Step 3: Fix Lock.pm for Git::Raw API**

```perl
# Simplified: use git command for now
sub can_acquire {
    my ($self, $agent) = @_;
    my $ref = $self->ref_path;
    my $content = `cd @{[$self->board_dir]} && git cat-file -p $ref 2>/dev/null`;
    return 1 unless $content;
    my ($locked_by) = $content =~ /^(\S+)/m;
    return !$locked_by || $locked_by eq $agent;
}

sub acquire {
    my ($self, $agent) = @_;
    return 0 unless $self->can_acquire($agent);
    my $ref = $self->ref_path;
    system("cd @{[$self->board_dir]} && git update-ref $ref $agent");
    return $? == 0;
}

docs/superpowers/plans/2026-03-15-karr-git-sync.md  view on Meta::CPAN

sub auto_sync {
    my ( $self, $task_id, $agent ) = @_;
    return if $self->no_sync;

    my $lock = App::karr::Lock->new(
        board_dir => $self->board_dir,
        task_id => $task_id,
    );

    unless ( $lock->acquire($agent) ) {
        die "Task $task_id is locked by another agent";
    }

    $self->sync->pull;
    # Do the actual operation
    $self->sync->push;
    $lock->release($agent);
}

1;
```

docs/superpowers/plans/2026-03-19-v0004-implementation.md  view on Meta::CPAN

    return $content;
}

sub acquire {
    my ( $self, $task_id, $email ) = @_;
    $task_id //= $self->task_id;
    my $ref = $self->ref_name($task_id);

    my $current = $self->get($task_id);
    if ( $current && $current ne $email ) {
        return ( 0, "locked by $current" );
    }

    $self->git->write_ref( $ref, $email );
    return ( 1, "acquired" );
}

sub release {
    my ( $self, $task_id, $email ) = @_;
    $task_id //= $self->task_id;
    my $ref = $self->ref_name($task_id);

    my $current = $self->get($task_id);
    if ( $current && $current ne $email ) {
        return ( 0, "locked by $current" );
    }

    $self->git->delete_ref($ref);
    return ( 1, "released" );
}

1;
```

- [ ] **Step 2: Run existing lock/git tests**

docs/superpowers/plans/2026-03-19-v0004-implementation.md  view on Meta::CPAN

my $git = App::karr::Git->new( dir => $repo );
my $lock = App::karr::Lock->new( git => $git );

# Agent A acquires lock on task 1
my ($ok1, $msg1) = $lock->acquire(1, 'agent-a@test.com');
ok $ok1, 'agent A acquires lock on task 1';

# Agent B tries to acquire same lock — fails
my ($ok2, $msg2) = $lock->acquire(1, 'agent-b@test.com');
ok !$ok2, 'agent B cannot lock task 1';
like $msg2, qr/locked by/, 'correct rejection message';

# Agent B acquires lock on task 2
my ($ok3, $msg3) = $lock->acquire(2, 'agent-b@test.com');
ok $ok3, 'agent B acquires lock on task 2';

# Agent A releases lock on task 1
my ($ok4, $msg4) = $lock->release(1, 'agent-a@test.com');
ok $ok4, 'agent A releases lock on task 1';

# Now anyone can lock task 1

docs/superpowers/plans/2026-03-19-v0004-implementation.md  view on Meta::CPAN

    my ($self, $args_ref, $chain_ref) = @_;

    $self->sync_before;

    my $config = App::karr::Config->new(
        file => $self->board_dir->child('config.yml'),
    );

    my @tasks = $self->load_tasks;

    # [existing filter logic stays the same: status, claimed, blocked, tags, sort]

    unless (@tasks) {
        print "No available tasks to pick.\n";
        return;
    }

    # Try to lock + claim
    require App::karr::Git;
    my $git = App::karr::Git->new(dir => $self->board_dir->parent->stringify);
    my $use_lock = $git->is_repo;

docs/superpowers/plans/2026-03-19-v0004-implementation.md  view on Meta::CPAN

                $task->started(gmtime->strftime('%Y-%m-%d'));
            }
        }

        $task->save;
        $picked = $task;
        last;
    }

    unless ($picked) {
        print "No available tasks to pick (all locked).\n";
        return;
    }

    # Serialize + push BEFORE releasing lock (spec ordering: sync then release)
    $self->sync_after;

    # Append log entry
    if ($use_lock) {
        $self->append_log($git,
            agent   => $self->claim,

docs/superpowers/plans/2026-03-22-helper-refs-and-docs.md  view on Meta::CPAN


### Task 1: Write the failing helper-ref tests

**Files:**
- Modify: `t/00-load.t`
- Create: `t/21-helper-refs.t`

- [ ] Add `App::karr::Cmd::SetRefs` and `App::karr::Cmd::GetRefs` to `t/00-load.t`.
- [ ] Write a failing integration test for:
  - bare ref normalization to `refs/...`,
  - blocked namespaces,
  - single-ref push/fetch between two repositories.
- [ ] Run `prove -l t/00-load.t t/21-helper-refs.t` and confirm red.

### Task 2: Implement Git helper methods

**Files:**
- Modify: `lib/App/karr/Git.pm`
- Test: `t/21-helper-refs.t`

- [ ] Add `normalize_ref_name`.

docs/superpowers/specs/2026-03-15-karr-git-sync-design.md  view on Meta::CPAN

```

**Task Metadata (YAML):**
```yaml
---
id: 1
title: "Fix login bug"
status: in-progress
claimed_by: agent-fox
priority: high
blocked_by: "waiting on API"
external:
  - type: github
    repo: owner/repo
    issue: 42
messages:
  - author: agent-fox
    text: "Ich arbeite dran"
    timestamp: 2026-03-15T10:00:00Z
  - author: agent-owl
    text: "Kann ich übernehmen?"

docs/superpowers/specs/2026-03-19-v0004-release-design.md  view on Meta::CPAN


Three agents on the same repo, each in a separate terminal/worktree.

```bash
# Agent A picks first available task:
karr pick --claim agent-a --move in-progress --json
# → picks task 3 (highest priority unclaimed)

# Agent B picks next available (task 3 is now claimed):
karr pick --claim agent-b --move in-progress --json
# → picks task 7 (next highest, task 3 locked by agent-a)

# Agent C picks:
karr pick --claim agent-c --move in-progress --json
# → picks task 12

# Agent A finishes, hands off:
karr handoff 3 --claim agent-a --note "done" -t
# Sync pushes → agents B and C see updated board on next sync

# Agent B checks what's blocked:
karr list --status review --json
```

**Integration hint:** Use `karr pick --claim $NAME --tags backend` to let agents specialize by domain. Tag tasks with `backend`, `frontend`, `docs` etc.

### Scenario 3: Cross-Repo Agent Coordination

Human manages GitHub Issues. Bridge agent transfers selected issues to karr. Worker agents pick and execute.

```

docs/superpowers/specs/2026-03-22-helper-refs-design.md  view on Meta::CPAN


- Accepts the same normalized ref syntax as `set-refs`.
- Fetches exactly the requested ref from the remote before reading.
- Writes status information to `stderr`.
- Writes only the ref payload to `stdout`.

## Validation

Requested refs must pass both structural validation and namespace validation.

### Blocked namespaces

The following namespaces are denied:

- `refs/heads/`
- `refs/tags/`
- `refs/remotes/`
- `refs/bisect/`
- `refs/replace/`
- `refs/stash`
- `refs/karr/`

docs/superpowers/specs/2026-03-22-helper-refs-design.md  view on Meta::CPAN

- empty path segments,
- leading or duplicate slashes,
- `..`,
- `@{`,
- control characters,
- spaces,
- Git-special characters such as `~ ^ : ? * [ \`,
- `.lock` suffixes,
- trailing dots.

Errors must distinguish between invalid ref syntax and blocked namespaces.

## Architecture

`App::karr::Git` remains the low-level Git boundary. The new feature extends it
with helper methods for:

- ref normalization,
- helper-ref validation,
- single-ref fetch,
- single-ref push.

docs/superpowers/specs/2026-03-22-helper-refs-design.md  view on Meta::CPAN

- `App::karr` gets a short section describing helper refs and the AI/agent use
  case.
- The new command modules receive full POD.
- Existing command and main-module POD should mention Docker as a peer runtime
  option after the Perl-first examples.
- `README.md` already covers Docker prominently, so the Perl POD only needs a
  concise nod to it rather than duplicating the full README.

## Verification

- Add focused tests for ref normalization, validation, and blocked namespaces.
- Add an integration test that pushes and fetches a helper ref across two Git
  repositories.
- Extend the load test to include the new command modules.
- Run `prove -l t`.
- Run `podchecker` across the modified modules.

lib/App/karr/Cmd/Board.pm  view on Meta::CPAN

    print "\n", $c->("## $label", "bold $accent"), "\n";

    for my $t (@$tasks) {
      my @meta;
      if ($t->priority && $t->priority ne 'medium') {
        push @meta, $c->('priority:' . $t->priority, $PRIORITY_COLOR{$t->priority} // 'white');
      }
      if ($t->has_claimed_by && $t->status ne 'done' && $t->status ne 'archived') {
        push @meta, $c->('@' . $t->claimed_by, 'cyan');
      }
      if ($t->has_blocked) {
        my $reason = $t->blocked;
        $reason = substr($reason, 0, 40) . '...' if defined $reason && length $reason > 43;
        push @meta, $c->(
          defined $reason && length $reason ? "blocked:$reason" : 'blocked', 'bold red');
      }
      if ($t->has_due) {
        push @meta, $c->('due:' . $t->due, 'yellow');
      }

      my $line = join ' ', $c->('-', 'bright_black'), $t->id, $sep, $t->title;
      $line .= " $sep " . join(" $sep ", @meta) if @meta;
      print $line, "\n";

      if ($self->tags && @{$t->tags}) {
        print '  ', $c->(join(' ', map { "#$_" } @{$t->tags}), 'bright_black'), "\n";
      }
    }
  }

  # Summary footer
  my $blocked = grep { $_->has_blocked } @tasks;
  my $claimed = grep { $_->has_claimed_by && $_->status ne 'done' && $_->status ne 'archived' } @tasks;
  my @summary = ( scalar(@tasks) . ' tasks' );
  push @summary, "$claimed claimed" if $claimed;
  push @summary, "$blocked blocked" if $blocked;
  print "\n", $c->(join('  ', @summary), 'bold'), "\n";
}

1;

__END__

=pod

=encoding UTF-8

lib/App/karr/Cmd/Board.pm  view on Meta::CPAN


=head1 OUTPUT MODES

=over 4

=item * Default output

Lists every status as a C<## Status> section (in board order, empty sections
included; an empty C<archived> is hidden). Each task renders as
C<- id | title> followed by C<priority> (non-default only), C<@claimant>,
C<blocked:reason>, and C<due:date> tokens where applicable. A footer line totals
tasks, claims, and blocks.

=item * C<--tags>

Adds an extra indented line of C<#tag> tokens beneath each task that has tags.

=item * C<--compact>

Prints one line per status in the form C<status(count): ids>.

lib/App/karr/Cmd/Context.pm  view on Meta::CPAN


option write_to => (
  is => 'ro',
  format => 's',
  doc => 'Write context to file (create or update)',
);

option sections => (
  is => 'ro',
  format => 's',
  doc => 'Comma-separated section filter (in-progress,blocked,overdue,recently-completed)',
);

option days => (
  is => 'ro',
  format => 'i',
  default => sub { 7 },
  doc => 'Lookback days for recently-completed (default: 7)',
);

sub execute {

lib/App/karr/Cmd/Context.pm  view on Meta::CPAN

  # Determine terminal and first statuses
  my $first_status = $statuses[0];

  # Exclude archived from all operations
  my @active_tasks = grep { !$self->store->is_terminal_status($_->status) } @tasks;

  # Build summary
  my $board_name = $ec->{board}{name} // 'Kanban Board';
  my $total = scalar @active_tasks;
  my $active = grep { $_->status ne $first_status && !$self->store->is_terminal_status($_->status) } @active_tasks;
  my $blocked = grep { $_->has_blocked } @active_tasks;
  my $overdue = $self->_count_overdue(\@active_tasks);

  # Build sections
  my %wanted_sections;
  if ($self->sections) {
    %wanted_sections = map { $_ => 1 } split /,/, $self->sections;
  }

  my @section_data;
  my @all_sections = qw(in-progress blocked overdue recently-completed);

  for my $sec (@all_sections) {
    next if $self->sections && !$wanted_sections{$sec};
    my @items;

    if ($sec eq 'in-progress') {
      @items = map { $self->_task_item($_) }
        sort { $self->_pri_order($a) <=> $self->_pri_order($b) }
        grep { $_->status ne $first_status && !$self->store->is_terminal_status($_->status) && !$_->has_blocked }
        @active_tasks;
    } elsif ($sec eq 'blocked') {
      @items = map { $self->_task_item($_, 'blocked: ' . ($_->blocked // '')) }
        grep { $_->has_blocked }
        @active_tasks;
    } elsif ($sec eq 'overdue') {
      my $now = gmtime->strftime('%Y-%m-%d');
      @items = map { $self->_task_item($_, 'due ' . $_->due) }
        grep { $_->has_due && $_->due lt $now && !$self->store->is_terminal_status($_->status) }
        @active_tasks;
    } elsif ($sec eq 'recently-completed') {
      my $cutoff = (gmtime() - ($self->days * 86400))->strftime('%Y-%m-%d');
      @items = map { $self->_task_item($_, 'completed ' . ($_->completed // '')) }
        sort { ($b->completed // '') cmp ($a->completed // '') }

lib/App/karr/Cmd/Context.pm  view on Meta::CPAN


    push @section_data, { name => $sec, items => \@items } if @items;
  }

  if ($self->json) {
    my $out = {
      board_name => $board_name,
      summary => {
        total_tasks => $total,
        active => $active,
        blocked => $blocked,
        overdue => $overdue,
      },
      sections => \@section_data,
    };
    $self->print_json($out);
    return;
  }

  # Render markdown
  my $md = $self->_render_markdown($board_name, $total, $active, $blocked, $overdue, \@section_data);

  if ($self->write_to) {
    $self->_write_to_file($md);
  } else {
    print $md;
  }
}

sub _render_markdown {
  my ($self, $board_name, $total, $active, $blocked, $overdue, $sections) = @_;
  my $md = "<!-- BEGIN kanban-md context -->\n";
  $md .= "## Board: $board_name\n\n";
  $md .= "**$total tasks** | $active active | $blocked blocked | $overdue overdue\n\n";

  my %section_title = (
    'in-progress'        => 'In Progress',
    'blocked'            => 'Blocked',
    'overdue'            => 'Overdue',
    'recently-completed' => 'Recently Completed',
  );

  for my $sec (@$sections) {
    $md .= "### " . ($section_title{$sec->{name}} // $sec->{name}) . "\n\n";
    for my $item (@{$sec->{items}}) {
      $md .= sprintf "- **#%d** %s (%s", $item->{id}, $item->{title}, $item->{priority};
      $md .= ", \@$item->{assignee}" if $item->{assignee};
      $md .= ")";

lib/App/karr/Cmd/Context.pm  view on Meta::CPAN


App::karr::Cmd::Context - Generate board context summary for embedding

=head1 VERSION

version 0.302

=head1 SYNOPSIS

    karr context
    karr context --sections blocked,overdue
    karr context --write-to AGENTS.md --days 14
    karr context --json

=head1 DESCRIPTION

Builds a concise board summary suitable for embedding into agent context files
such as F<AGENTS.md>. The command can print Markdown directly, emit structured
JSON, or update an existing file between sentinel comments.

=head1 SECTIONS

The generated context can include C<in-progress>, C<blocked>, C<overdue>, and
C<recently-completed>. Use C<--sections> with a comma-separated list to limit
the output to a subset.

=head1 FILE UPDATE MODE

When C<--write-to> is used, the command replaces the content between
C<BEGIN kanban-md context> and C<END kanban-md context> if those sentinels are
already present; otherwise it appends the generated block to the file.

=head1 SEE ALSO

lib/App/karr/Cmd/Edit.pm  view on Meta::CPAN

);

option release => (
  is => 'ro',
  doc => 'Release claim',
);

option block => (
  is => 'ro',
  format => 's',
  doc => 'Mark as blocked with reason',
);

option unblock => (
  is => 'ro',
  doc => 'Clear blocked state',
);

sub execute {
  my ($self, $args_ref, $chain_ref) = @_;

  $self->sync_before;

  my $id_str = $args_ref->[0] or die "Usage: karr edit ID[,ID,...] [FLAGS]\n";
  my @ids = $self->parse_ids($id_str);

lib/App/karr/Cmd/Edit.pm  view on Meta::CPAN

      $task->claimed_by($self->claim);
      $task->claimed_at(gmtime->datetime . 'Z');
    }

    if ($self->release) {
      $task->clear_claimed_by;
      $task->clear_claimed_at;
    }

    if ($self->block) {
      $task->blocked($self->block);
    }

    if ($self->unblock) {
      $task->clear_blocked;
    }

    $self->save_task($task);

    push @results, { id => $task->id, title => $task->title };
    printf "Updated task %d: %s\n", $task->id, $task->title unless $self->json;
  }

  $self->sync_after;

lib/App/karr/Cmd/Edit.pm  view on Meta::CPAN

=head1 SYNOPSIS

    karr edit 5 --title "Updated title"
    karr edit 5 --add-tag urgent --remove-tag stale
    karr edit 5 -a "Waiting for review"
    karr edit 5 --claim agent-fox --block "waiting on API"

=head1 DESCRIPTION

Updates one or more existing tasks in place. Use it to adjust metadata, append
notes, manage tags, claim or release ownership, and mark tasks as blocked or
unblocked without changing the task id.

=head1 COMMON OPERATIONS

=over 4

=item * Metadata updates

C<--title>, C<--status>, C<--priority>, C<--assignee>, and C<--due> replace
existing values.

lib/App/karr/Cmd/Handoff.pm  view on Meta::CPAN

  if ($task->status ne 'review') {
    $task->status('review');
  }

  # Refresh claim
  $task->claimed_by($self->claim);
  $task->claimed_at(gmtime->datetime . 'Z');

  # Block if requested
  if ($self->block) {
    $task->blocked($self->block);
  }

  # Append note
  if ($self->note) {
    my $note_text = $self->note;
    if ($self->timestamp) {
      $note_text = gmtime->strftime('%Y-%m-%d %H:%M') . ' ' . $note_text;
    }
    $task->body(($task->body ? $task->body . "\n" : '') . $note_text);
  }

lib/App/karr/Cmd/Handoff.pm  view on Meta::CPAN

  $self->sync_after;

  if ($self->json) {
    my $data = $task->to_frontmatter;
    $data->{body} = $task->body if $task->body;
    $self->print_json($data);
    return;
  }

  my $msg = sprintf "Handed off task %d -> review", $task->id;
  $msg .= sprintf " (blocked: %s)", $self->block if $self->block;
  $msg .= " (claim released)" if $self->release;
  print "$msg\n";
}

1;

__END__

=pod

lib/App/karr/Cmd/List.pm  view on Meta::CPAN

    }
    return;
  }

  printf "%-5s %10s %s\n", 'ID', 'STATUS', 'TITLE';
  printf "%s\n", '-' x 72;
  for my $t (@tasks) {
    my @meta;
    push @meta, $t->priority if defined $t->priority && length $t->priority;
    push @meta, '@' . $t->assignee if $t->has_assignee;
    push @meta, 'blocked' if $t->has_blocked;
    my $title = $t->title;
    $title .= ' [' . join(', ', @meta) . ']' if @meta;

    printf "#%-4u %10s %s\n",
      $t->id,
      $t->status,
      $title;
  }
  printf "\n%d task(s)\n", scalar @tasks;
}

lib/App/karr/Cmd/Pick.pm  view on Meta::CPAN

    # Exclude terminal statuses
    @tasks = grep { !App::karr::Config->is_terminal_status($_->status) } @tasks;
  }

  # Exclude claimed tasks (unless claim expired)
  my $timeout = $self->_parse_timeout($ec->{claim_timeout} // '1h');
  @tasks = grep {
    !$_->has_claimed_by || $self->_claim_expired($_, $timeout)
  } @tasks;

  # Exclude blocked
  @tasks = grep { !$_->has_blocked } @tasks;

  # Filter by tags
  if ($self->tags) {
    my %wanted = map { $_ => 1 } split /,/, $self->tags;
    @tasks = grep {
      my $t = $_;
      grep { $wanted{$_} } @{$t->tags};
    } @tasks;
  }

lib/App/karr/Cmd/Pick.pm  view on Meta::CPAN

        $task->started(gmtime->strftime('%Y-%m-%d'));
      }
    }

    $self->save_task($task);
    $picked = $task;
    last;
  }

  unless ($picked) {
    print "No available tasks to pick (all locked).\n";
    return;
  }

  # Serialize + push BEFORE releasing lock
  $self->sync_after;

  # Log the pick action
  if ($use_lock) {
    $self->append_log($self->git,
      agent   => $self->claim,

lib/App/karr/Cmd/Pick.pm  view on Meta::CPAN


=head1 SYNOPSIS

    karr pick --claim agent-fox
    karr pick --claim agent-fox --status todo --move in-progress
    karr pick --claim agent-fox --tags backend,urgent --json

=head1 DESCRIPTION

Selects the next available task for an agent, taking class of service,
priority, blocked state, and claim expiry into account. When the board lives in
a Git repository, the command also uses lock refs so concurrent agents do not
pick the same task.

=head1 SELECTION RULES

=over 4

=item * Eligible statuses

If C<--status> is omitted, tasks in C<done> and C<archived> are excluded.

lib/App/karr/Cmd/Show.pm  view on Meta::CPAN


  printf "Task #%d: %s\n", $task->id, $task->title;
  printf "Status:   %s\n", $task->status;
  printf "Priority: %s\n", $task->priority;
  printf "Class:    %s\n", $task->class;
  printf "Assignee: %s\n", $task->assignee if $task->has_assignee;
  printf "Tags:     %s\n", join(', ', @{$task->tags}) if @{$task->tags};
  printf "Due:      %s\n", $task->due if $task->has_due;
  printf "Estimate: %s\n", $task->estimate if $task->has_estimate;
  printf "Claimed:  %s\n", $task->claimed_by if $task->has_claimed_by;
  printf "Blocked:  %s\n", $task->blocked if $task->has_blocked;
  printf "Created:  %s\n", $task->created;
  printf "Updated:  %s\n", $task->updated;
  if ($task->body) {
    print "\n" . $task->body . "\n";
  }
}

# Tasks sorted most-recently-updated first.
sub _by_updated {
  my ($self, @tasks) = @_;

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

  # claude: true synthesis). Agent execution is opt-in: a board with no agent
  # is shown in the overview, not run.
  my $cmd = $self->_agent_command( $repo, $karr );
  unless ( defined $cmd ) {
    $self->_say_verbose("skip $repo — no agent configured (see --status)");
    return;
  }

  # Check lock — skip if another instance is running
  if ( $self->_lock_held( $repo ) ) {
    $self->_say_verbose("skip $repo — locked by running agent");
    return;
  }

  # Respect exponential cooldown left by a previous common-error run
  if ( $self->_cooldown_active( $repo ) ) {
    my $until = $self->_state_get( $repo, 'cooldown_until' ) // 0;
    $self->_say_verbose( "skip $repo — in cooldown for " . ( $until - time ) . "s" );
    return;
  }

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

  # Deterministic fingerprint of refs/karr/* (ref name + target OID).
  my $out = join '', map { "$_ $oids->{$_}\n" } sort keys %$oids;
  return md5_hex( $out );
}

# ---------------------------------------------------------------------------
# Task state / actionability
# ---------------------------------------------------------------------------

# A task is actionable when an agent could still pick it: not terminal
# (done/archived) and not blocked. Mirrors `karr pick` eligibility.
sub _is_actionable {
  my ( $self, $st ) = @_;
  return 0 unless $st;
  return 0 if $st->{blocked};
  my $status = $st->{status} // '';
  return 0 if $status eq 'done' || $status eq 'archived';
  return 1;
}

# Snapshot every task as id => { status, claimed_by, updated, blocked }.
sub _task_states {
  my ( $self, $repo ) = @_;
  my $git = App::karr::Git->new( dir => "$repo" );
  return () unless $git->is_repo;
  my $store = App::karr::BoardStore->new( git => $git );
  my %states;
  for my $t ( $store->load_tasks ) {
    next unless $t;
    $states{ $t->id } = {
      status     => $t->status,
      claimed_by => ( $t->has_claimed_by ? $t->claimed_by : undef ),
      updated    => $t->updated,
      blocked    => ( $t->has_blocked ? 1 : 0 ),
    };
  }
  return %states;
}

sub _has_actionable_tasks {
  my ( $self, $repo ) = @_;
  my %states = $self->_task_states( $repo );
  for my $id ( keys %states ) {
    return 1 if $self->_is_actionable( $states{$id} );

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

    }

    my $hash_after = $self->_ref_hash( $repo ) // '';
    my $progressed = ( $hash_before ne $hash_after ) ? 1 : 0;
    $outcome = 'progress' if $progressed;

    my %after = $self->_task_states( $repo );
    my @stuck = $self->_stuck_tasks( \%before, \%after );

    # Reset the attempt counter for any task that is no longer stuck
    # (advanced, blocked, or gone), then bump/auto-block the stuck ones.
    my %is_stuck = map { $_ => 1 } @stuck;
    my $attempts = $self->_state_get( $repo, 'attempts' ) // {};
    $self->_reset_attempts( $repo, $_ ) for grep { !$is_stuck{$_} } keys %$attempts;

    for my $id ( @stuck ) {
      my $n = $self->_bump_attempts( $repo, $id );
      next if $n < $max_attempts;
      $self->_autoblock_task( $repo, $id,
        "auto-block: no progress after $n attempts (foundation)" );
      $self->_reset_attempts( $repo, $id );

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

# Auto-block (in-process via BoardStore, no karr CLI)
# ---------------------------------------------------------------------------

sub _autoblock_task {
  my ( $self, $repo, $id, $reason ) = @_;
  return if $self->dry_run;
  my $git = App::karr::Git->new( dir => "$repo" );
  return unless $git->is_repo;
  my $store = App::karr::BoardStore->new( git => $git );
  my $task  = $store->find_task( $id ) or return;
  $task->blocked( $reason );
  $store->save_task( $task );
  $git->push;   # best-effort propagate to remote
  $self->_append_log( $repo, "AUTOBLOCK task#$id: $reason" );
  return 1;
}

# ---------------------------------------------------------------------------
# Exponential cooldown (1, 2, 4, 8, ... minutes, capped) on common-error
# ---------------------------------------------------------------------------

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

# Overview (read-only dashboard)
# ---------------------------------------------------------------------------

sub _print_overview {
  my ( $self, $repos ) = @_;
  for my $repo (@$repos) {
    my $karr   = $self->_load_karr($repo);
    my %states = $self->_task_states($repo);

    my %count;
    my ( @in_progress, @blocked );
    for my $id ( sort { $a <=> $b } keys %states ) {
      my $st = $states{$id};
      $count{ $st->{status} // 'unknown' }++;
      push @in_progress, $id if ( $st->{status} // '' ) eq 'in-progress';
      push @blocked,     $id if $st->{blocked};
    }

    my @flags;
    push @flags, 'agent-running' if $self->_lock_held($repo);
    if ( $self->_cooldown_active($repo) ) {
      my $until = $self->_state_get( $repo, 'cooldown_until' ) // 0;
      push @flags, 'cooldown ' . ( $until - time ) . 's';
    }
    push @flags, 'agent' if defined $self->_agent_command( $repo, $karr );

    my $total = keys %states;
    printf "%s\n", $repo->basename;
    printf "  %d tasks", $total;
    print '  [' . join( ', ', @flags ) . ']' if @flags;
    print "\n";
    if (%count) {
      printf "  %s\n", join( '  ', map { "$_:$count{$_}" } sort keys %count );
    }
    printf "  in-progress: %s\n", join( ', ', map { "#$_" } @in_progress ) if @in_progress;
    printf "  blocked:     %s\n", join( ', ', map { "#$_" } @blocked )     if @blocked;
    print "\n";
  }
  return;
}

1;

__END__

=pod

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

  error_patterns:           # extra case-insensitive substrings → common-error
    - my custom api error

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

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

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


B<Drain semantics.> Each iteration runs C<command> once, then classifies the
result from what foundation can observe — exit code, board ref movement, and
the run's captured output:

=over 4

=item * B<progress> — the board changed; keep draining.

=item * B<stall> — a task the agent claimed / left C<in-progress> did not move.
That task's attempt counter is bumped; at C<max_attempts> it is auto-blocked
(C<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 C<karr edit --block>; the auto-block is a fallback.

=item * B<common-error> — a non-zero/timeout exit or a C<error_patterns> match
(rate limit, auth, network, 5xx, …). No task is penalized; the repo enters an
exponential cooldown (C<cooldown_base> × 2^level minutes, capped at
C<cooldown_max>, reset on the next clean run) and is skipped until it expires.

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

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

    my ( $self, $ref ) = @_;
    defined $ref or die "Ref name is required\n";
    $ref =~ s{^/+}{};
    return $ref =~ m{^refs/} ? $ref : "refs/$ref";
}

sub validate_helper_ref {
    my ( $self, $ref ) = @_;
    my $full_ref = $self->normalize_ref_name($ref);

    my @blocked = (
        'refs/heads/',
        'refs/tags/',
        'refs/remotes/',
        'refs/bisect/',
        'refs/replace/',
        'refs/karr/',
    );

    for my $prefix (@blocked) {
        die "Ref '$full_ref' is in a protected namespace\n"
            if index( $full_ref, $prefix ) == 0;
    }
    die "Ref '$full_ref' is in a protected namespace\n"
        if $full_ref eq 'refs/stash' || index( $full_ref, 'refs/stash/' ) == 0;

    # Native validity check via Git::Native.
    die "Ref '$full_ref' is not a valid git ref name\n"
        unless Git::Native->reference_name_is_valid($full_ref);

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

    return $content;
}

sub acquire {
    my ( $self, $task_id, $email ) = @_;
    $task_id //= $self->task_id;
    my $ref = $self->ref_name($task_id);

    my $current = $self->get($task_id);
    if ( $current && $current ne $email ) {
        return ( 0, "locked by $current" );
    }

    $self->git->write_ref( $ref, $email );
    return ( 1, "acquired" );
}

sub release {
    my ( $self, $task_id, $email ) = @_;
    $task_id //= $self->task_id;
    my $ref = $self->ref_name($task_id);

    my $current = $self->get($task_id);
    if ( $current && $current ne $email ) {
        return ( 0, "locked by $current" );
    }

    $self->git->delete_ref($ref);
    return ( 1, "released" );
}

1;

__END__

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

has due        => ( is => 'rw', predicate => 1, clearer => 1 );
has estimate   => ( is => 'rw', predicate => 1, clearer => 1 );
has class      => ( is => 'rw', default => sub { 'standard' } );
has parent     => ( is => 'rw', predicate => 1, clearer => 1 );
has depends_on => ( is => 'rw', default => sub { [] } );
has body       => ( is => 'rw', default => sub { '' } );
has created    => ( is => 'ro', default => sub { gmtime->datetime . 'Z' } );
has updated    => ( is => 'rw', default => sub { gmtime->datetime . 'Z' } );
has claimed_by => ( is => 'rw', predicate => 1, clearer => 1 );
has claimed_at => ( is => 'rw', predicate => 1, clearer => 1 );
has blocked    => ( is => 'rw', predicate => 1, clearer => 1 );
has started    => ( is => 'rw', predicate => 1, clearer => 1 );
has completed  => ( is => 'rw', predicate => 1, clearer => 1 );
has file_path  => ( is => 'rw', predicate => 1 );

# Optional fields are addressed through their predicate everywhere (pick,
# board, list, show, handoff all treat has_X as "is this set"). Clearing one
# by assigning undef would leave the predicate true, so callers must use the
# generated clear_X. This guards the load path: a file that carries an
# explicit null (our own older writes, or an external kanban-md edit) is
# normalized back to "unset" instead of lingering as has_X-true-but-undef.
sub BUILD {
  my ($self) = @_;
  for my $attr (qw( assignee due estimate parent claimed_by claimed_at blocked started completed )) {
    my $clearer = "clear_$attr";
    my $has     = "has_$attr";
    $self->$clearer if $self->$has && !defined $self->$attr;
  }
}

sub slug {
  my ($self) = @_;
  my $slug = lc($self->title);
  $slug =~ s/[^a-z0-9]+/-/g;

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

    class    => $self->class,
  );
  $fm{assignee}   = $self->assignee   if $self->has_assignee;
  $fm{tags}       = $self->tags       if @{$self->tags};
  $fm{due}        = $self->due        if $self->has_due;
  $fm{estimate}   = $self->estimate   if $self->has_estimate;
  $fm{parent}     = $self->parent     if $self->has_parent;
  $fm{depends_on} = $self->depends_on if @{$self->depends_on};
  $fm{claimed_by} = $self->claimed_by if $self->has_claimed_by;
  $fm{claimed_at} = $self->claimed_at if $self->has_claimed_at;
  $fm{blocked}    = $self->blocked    if $self->has_blocked;
  $fm{started}    = $self->started    if $self->has_started;
  $fm{completed}  = $self->completed  if $self->has_completed;
  return \%fm;
}

sub to_markdown {
  my ($self) = @_;
  my $yaml = Dump($self->to_frontmatter);
  $yaml =~ s/\A---\n//;
  my $md = "---\n${yaml}---\n";

share/claude-skill.md  view on Meta::CPAN


### Edit task

```bash
karr edit ID --title "New title"
karr edit ID --priority high --add-tag urgent
karr edit ID --body "New description"
karr edit ID -a "Appended note"              # append to body
karr edit ID --claim agent-1                 # claim
karr edit ID --release                       # release claim
karr edit ID --block "Waiting on API"        # mark blocked
karr edit ID --unblock                       # clear blocked
```

### Delete task

```bash
karr delete ID --yes                         # skip confirmation
```

### Archive task

share/claude-skill.md  view on Meta::CPAN

Shows tasks grouped by status.

### Pick next task (multi-agent)

```bash
karr pick --claim agent-1                    # pick highest priority available
karr pick --claim agent-1 --status todo --move in-progress
karr pick --claim agent-1 --tags backend
```

Atomically finds and claims the next available task. Respects claim timeouts, blocked state, and class-of-service priority ordering (expedite > fixed-date > standard > intangible).

### Handoff task for review

```bash
karr handoff ID --claim agent-1              # move to review, refresh claim
karr handoff ID --claim agent-1 --note "Done, needs QA" --timestamp
karr handoff ID --claim agent-1 --block "waiting for feedback" --release
```

Moves task to `review`, refreshes claim, optionally appends a timestamped note, blocks, or releases the claim.

share/claude-skill.md  view on Meta::CPAN

karr config --json                           # JSON output
```

Writable keys: `board.name`, `board.description`, `defaults.status`, `defaults.priority`, `defaults.class`, `claim_timeout`.

### Context (board summary for embedding)

```bash
karr context                                 # print markdown summary
karr context --write-to AGENTS.md            # create/update file with sentinels
karr context --sections blocked,overdue      # filter sections
karr context --days 14                       # lookback for recently-completed
karr context --json                          # JSON output
```

Generates a markdown summary with sections: In Progress, Blocked, Overdue, Recently Completed. Uses `<!-- BEGIN kanban-md context -->` / `<!-- END kanban-md context -->` sentinels for in-place updates.

### Skill management

```bash
karr skill install                           # install skill for detected agents
karr skill install --agent claude-code       # install for specific agent
karr skill install --global                  # install globally (~/)
karr skill install --force                   # force reinstall
karr skill check                             # check if installed skills are current
karr skill update                            # update outdated skills

share/claude-skill.md  view on Meta::CPAN

is kept separately in `refs/karr/meta/next-id`.

## Decision tree: which command?

1. **Need a board?** → `karr init`
2. **New work item?** → `karr create "Title" --priority high`
3. **What's on the board?** → `karr board` or `karr list`
4. **Starting work?** → `karr pick --claim NAME --move in-progress`
5. **Done with task, hand to review?** → `karr handoff ID --claim NAME --note "reason"`
6. **Done with task, close it?** → `karr edit ID --release && karr move ID done`
7. **Blocked?** → `karr edit ID --block "reason"`
8. **Need details?** → `karr show ID`
9. **Soft-delete?** → `karr archive ID`
10. **Board snapshot for agent context?** → `karr context --write-to AGENTS.md`
11. **Check/change config?** → `karr config` / `karr config set KEY VALUE`
12. **Install agent skills?** → `karr skill install`
13. **Need a full board snapshot?** → `karr backup` / `karr restore --yes`
14. **Need shared non-task workflow data?** → `karr set-refs` / `karr get-refs`
15. **Need to remove the board completely?** → `karr destroy --yes`

## Multi-agent workflow

t/04-handoff.t  view on Meta::CPAN

  my $dir = tempdir(CLEANUP => 1);
  my $tasks = path($dir);

  my $task = App::karr::Task->new(
    id => 3, title => 'Block Test', status => 'in-progress',
  );
  $task->save($tasks->stringify);

  my $loaded = App::karr::Task->from_file($tasks->child('003-block-test.md'));
  $loaded->status('review');
  $loaded->blocked('waiting for feedback');
  $loaded->save;

  my $after = App::karr::Task->from_file($tasks->child('003-block-test.md'));
  is $after->blocked, 'waiting for feedback', 'blocked with reason';
};

done_testing;

t/07-context.t  view on Meta::CPAN

  $tasks->mkpath;
  DumpFile($board->child('config.yml')->stringify, App::karr::Config->default_config);

  # Create tasks in various states
  App::karr::Task->new(
    id => 1, title => 'Active Task', status => 'in-progress',
    priority => 'high',
  )->save($tasks->stringify);

  App::karr::Task->new(
    id => 2, title => 'Blocked Task', status => 'in-progress',
    priority => 'medium', blocked => 'waiting on API',
  )->save($tasks->stringify);

  App::karr::Task->new(
    id => 3, title => 'Done Task', status => 'done',
    priority => 'low', completed => gmtime->strftime('%Y-%m-%d'),
  )->save($tasks->stringify);

  # Load and verify tasks
  my @files = sort $tasks->children(qr/\.md$/);
  is scalar @files, 3, 'three task files created';

  my @loaded = map { App::karr::Task->from_file($_) } @files;
  my @in_progress = grep { $_->status eq 'in-progress' && !$_->has_blocked } @loaded;
  is scalar @in_progress, 1, 'one active non-blocked task';

  my @blocked = grep { $_->has_blocked } @loaded;
  is scalar @blocked, 1, 'one blocked task';
};

subtest 'write-to with sentinels' => sub {
  my $dir = tempdir(CLEANUP => 1);
  my $file = path($dir)->child('AGENTS.md');

  # Write initial content
  my $context = "<!-- BEGIN kanban-md context -->\n## Board: Test\n<!-- END kanban-md context -->\n";
  $file->spew_utf8("# My Agents\n\n" . $context);

t/16-pick-lock.t  view on Meta::CPAN

my $git = App::karr::Git->new( dir => $repo );
my $lock = App::karr::Lock->new( git => $git );

# Agent A acquires lock on task 1
my ($ok1, $msg1) = $lock->acquire(1, 'agent-a@test.com');
ok $ok1, 'agent A acquires lock on task 1';

# Agent B tries same lock — fails
my ($ok2, $msg2) = $lock->acquire(1, 'agent-b@test.com');
ok !$ok2, 'agent B cannot lock task 1';
like $msg2, qr/locked by/, 'correct rejection message';

# Agent B acquires lock on task 2
my ($ok3, $msg3) = $lock->acquire(2, 'agent-b@test.com');
ok $ok3, 'agent B acquires lock on task 2';

# Agent A releases
$lock->release(1, 'agent-a@test.com');
my ($ok5, $msg5) = $lock->acquire(1, 'agent-b@test.com');
ok $ok5, 'agent B can lock task 1 after release';

t/21-helper-refs.t  view on Meta::CPAN


    ok(
        eval { $git->validate_helper_ref('refs/superpowers/spec/1234.md'); 1 },
        'non-reserved helper ref is accepted'
    ) or diag $@;

    ok(
        !eval { $git->validate_helper_ref('refs/heads/main'); 1 },
        'heads namespace is rejected'
    );
    like( $@, qr/protected|blocked/i, 'blocked namespace error is descriptive' );
};

subtest 'set-refs and get-refs roundtrip over a remote' => sub {
    my $bare = _init_bare_remote();

    my $repo_a = tempdir( CLEANUP => 1 );
    _init_repo( $repo_a, 'a@test.com', 'Agent A' );
    _git_ok( 'git', '-C', $repo_a, 'remote', 'add', 'origin', $bare );
    my $branch = _default_branch($repo_a);
    _git_ok( 'git', '-C', $repo_a, 'push', 'origin', $branch );

t/21-helper-refs.t  view on Meta::CPAN

    like( $get->{stderr}, qr{refs/superpowers/spec/1234\.md}, 'get-refs reports fetch/read status on stderr' );
};

subtest 'protected namespaces are rejected from the CLI' => sub {
    my $repo = tempdir( CLEANUP => 1 );
    _init_repo( $repo, 'test@example.com', 'Test User' );

    my $rv = _run_karr( $repo, 'set-refs', 'heads/main', 'nope' );
    isnt( $rv->{exit}, 0, 'set-refs fails for protected namespaces' );
    is( $rv->{stdout}, '', 'error path keeps stdout empty' );
    like( $rv->{stderr}, qr/protected|blocked/i, 'stderr explains why the ref is rejected' );
};

done_testing;

t/31-foundation-drain.t  view on Meta::CPAN

  $script->spew_utf8(<<'PERL');
use strict;
use warnings;
my $repo = $ENV{KARR_REPO} or die "no KARR_REPO\n";
my $mode = $ENV{KARR_FAKE_MODE} // 'progress';
require App::karr::Git;
require App::karr::BoardStore;
my $store = App::karr::BoardStore->new(
  git => App::karr::Git->new( dir => $repo ) );
my @open = grep {
  $_ && !$_->has_blocked && $_->status ne 'done' && $_->status ne 'archived'
} $store->load_tasks;
if ( $mode eq 'progress' ) {
  if ( my $t = $open[0] ) { $t->status('done'); $store->save_task($t); }
}
elsif ( $mode eq 'claim-stall' ) {
  if ( my $t = $open[0] ) {
    if ( $t->status ne 'in-progress' ) {
      $t->status('in-progress');
      $t->claimed_by('fake-agent');
      $store->save_task($t);

t/31-foundation-drain.t  view on Meta::CPAN

# Unit: actionability
# ---------------------------------------------------------------------------

subtest '_is_actionable' => sub {
  my $f = App::karr::Foundation->new;
  ok   $f->_is_actionable({ status => 'todo' }),                 'todo actionable';
  ok   $f->_is_actionable({ status => 'in-progress' }),          'in-progress actionable';
  ok   $f->_is_actionable({ status => 'backlog' }),              'backlog actionable';
  ok ! $f->_is_actionable({ status => 'done' }),                 'done not actionable';
  ok ! $f->_is_actionable({ status => 'archived' }),             'archived not actionable';
  ok ! $f->_is_actionable({ status => 'todo', blocked => 1 }),   'blocked not actionable';
  ok ! $f->_is_actionable(undef),                                'undef not actionable';
};

subtest '_has_actionable_tasks' => sub {
  my $repo = make_git_repo();
  seed_board( $repo, { status => 'done' }, { status => 'todo', blocked => 'x' } );
  my $f = App::karr::Foundation->new;
  ok ! $f->_has_actionable_tasks( $repo ), 'done + blocked => none actionable';
  seed_board( $repo, { status => 'todo' } );
  ok $f->_has_actionable_tasks( $repo ), 'a todo makes it actionable';
};

# ---------------------------------------------------------------------------
# Unit: common-error detection
# ---------------------------------------------------------------------------

subtest 'error patterns' => sub {
  my $f = App::karr::Foundation->new;

t/31-foundation-drain.t  view on Meta::CPAN

    2 => { status => 'todo',        claimed_by => undef, updated => 'T1' },
    3 => { status => 'in-progress', claimed_by => 'a', updated => 'T1' },
  };
  my $after = {
    1 => { status => 'in-progress', claimed_by => 'a', updated => 'T1' },  # unchanged -> stuck
    2 => { status => 'todo',        claimed_by => undef, updated => 'T1' }, # not claimed -> ignore
    3 => { status => 'done',        claimed_by => 'a', updated => 'T2' },  # advanced -> not stuck
  };
  is_deeply [ $f->_stuck_tasks( $before, $after ) ], [1], 'only the unchanged claimed task is stuck';

  # blocked task is never stuck
  my $b2 = { 1 => { status => 'in-progress', claimed_by => 'a', updated => 'T1' } };
  my $a2 = { 1 => { status => 'in-progress', claimed_by => 'a', updated => 'T1', blocked => 1 } };
  is_deeply [ $f->_stuck_tasks( $b2, $a2 ) ], [], 'blocked task drops out';
};

# ---------------------------------------------------------------------------
# Unit: attempts counter
# ---------------------------------------------------------------------------

subtest 'attempts counter' => sub {
  my $repo = tempdir( CLEANUP => 1 );
  my $f = App::karr::Foundation->new;
  is $f->_bump_attempts( $repo, 7 ), 1, 'first bump => 1';

t/31-foundation-drain.t  view on Meta::CPAN

# ---------------------------------------------------------------------------
# Unit: auto-block via BoardStore
# ---------------------------------------------------------------------------

subtest '_autoblock_task' => sub {
  my $repo = make_git_repo();
  seed_board( $repo, { status => 'in-progress', claimed_by => 'a' } );
  my $f = App::karr::Foundation->new;
  ok $f->_autoblock_task( $repo, 1, 'auto: nope' ), 'autoblock returns true';
  my $t = task_by_id( $repo, 1 );
  ok $t->has_blocked, 'task is blocked';
  is $t->blocked, 'auto: nope', 'block reason stored';
};

# ---------------------------------------------------------------------------
# Integration: drain to completion on progress
# ---------------------------------------------------------------------------

subtest 'drain completes when agent makes progress' => sub {
  my $repo  = make_git_repo();
  seed_board( $repo, { status => 'todo' }, { status => 'todo' }, { status => 'todo' } );
  my $agent = write_fake_agent( $repo );

t/31-foundation-drain.t  view on Meta::CPAN

  local $ENV{KARR_FAKE_MODE} = 'progress';
  my $res = $f->_drain_repo( $repo, { command => $agent, max_runtime => 60 } );

  is $res->{outcome}, 'progress', 'outcome progress';
  ok ! $f->_has_actionable_tasks( $repo ), 'board fully drained (all done)';
  is task_by_id( $repo, 1 )->status, 'done', 'task 1 done';
  is task_by_id( $repo, 3 )->status, 'done', 'task 3 done';
};

# ---------------------------------------------------------------------------
# Integration: stuck task gets auto-blocked, drain then terminates
# ---------------------------------------------------------------------------

subtest 'stalled task is auto-blocked' => sub {
  my $repo  = make_git_repo();
  seed_board( $repo, { status => 'todo' } );
  my $agent = write_fake_agent( $repo );

  my $f = App::karr::Foundation->new;
  local $ENV{KARR_FAKE_MODE} = 'claim-stall';
  my $res = $f->_drain_repo( $repo,
    { command => $agent, max_runtime => 60, max_attempts => 2 } );

  my $t = task_by_id( $repo, 1 );
  ok $t->has_blocked, 'stuck task ends up blocked';
  like $t->blocked, qr/auto-block: no progress/, 'auto-block reason set';
  ok ! $f->_has_actionable_tasks( $repo ), 'no actionable tasks left -> drain done';
};

# ---------------------------------------------------------------------------
# Integration: common error backs off, never blocks a task
# ---------------------------------------------------------------------------

subtest 'common error stops drain without blocking' => sub {
  my $repo  = make_git_repo();
  seed_board( $repo, { status => 'todo' } );
  my $agent = write_fake_agent( $repo );

  my $f = App::karr::Foundation->new;
  local $ENV{KARR_FAKE_MODE} = 'error';
  my $res = $f->_drain_repo( $repo, { command => $agent, max_runtime => 60 } );

  is $res->{outcome}, 'common-error', 'outcome common-error';
  ok ! task_by_id( $repo, 1 )->has_blocked, 'task NOT blocked on infra error';
  is $f->_state_get( $repo, 'last_error' ), 'rate limit', 'last_error recorded';
};

# ---------------------------------------------------------------------------
# Integration: drain=false runs the command only once
# ---------------------------------------------------------------------------

subtest 'drain=false is single-shot' => sub {
  my $repo  = make_git_repo();
  seed_board( $repo, { status => 'todo' }, { status => 'todo' }, { status => 'todo' } );

t/36-task-clear.t  view on Meta::CPAN


# Optional fields are addressed through their predicate everywhere in the code
# base. Clearing one must drop the predicate, not leave it true-but-undef, and
# a cleared field must not be serialized (otherwise a reload re-creates the
# stale predicate). Regression for the release/unblock round trip.

subtest 'clear_X drops the predicate in memory' => sub {
  my $task = App::karr::Task->new( id => 1, title => 'x' );
  $task->claimed_by('agent-a');
  $task->claimed_at('2026-01-01T00:00:00Z');
  $task->blocked('waiting on API');
  ok $task->has_claimed_by, 'claimed_by set';
  ok $task->has_blocked,    'blocked set';

  $task->clear_claimed_by;
  $task->clear_claimed_at;
  $task->clear_blocked;
  ok !$task->has_claimed_by, 'clear_claimed_by drops predicate';
  ok !$task->has_claimed_at, 'clear_claimed_at drops predicate';
  ok !$task->has_blocked,    'clear_blocked drops predicate';
};

subtest 'cleared fields are not serialized and survive a reload' => sub {
  my $task = App::karr::Task->new( id => 2, title => 'y' );
  $task->claimed_by('agent-a');
  $task->claimed_at('2026-01-01T00:00:00Z');
  $task->clear_claimed_by;
  $task->clear_claimed_at;

  my $md = $task->to_markdown;

t/36-task-clear.t  view on Meta::CPAN

---
id: 3
title: legacy null
status: todo
priority: medium
class: standard
created: 2026-03-19T10:00:00Z
updated: 2026-03-19T10:00:00Z
claimed_by: ~
claimed_at: ~
blocked: ~
---
MD
  ok !$legacy->has_claimed_by, 'null claimed_by loads as unset';
  ok !$legacy->has_claimed_at, 'null claimed_at loads as unset';
  ok !$legacy->has_blocked,    'null blocked loads as unset';
};

done_testing;

t/37-board-render.t  view on Meta::CPAN

  my (%a) = @_;
  my $t = App::karr::Task->new(
    id       => $a{id},
    title    => $a{title},
    status   => $a{status},
    priority => $a{priority} // 'medium',
    class    => 'standard',
  );
  $t->due( $a{due} )               if $a{due};
  $t->claimed_by( $a{claimed_by} ) if $a{claimed_by};
  $t->blocked( $a{blocked} )       if $a{blocked};
  $t->tags( $a{tags} )             if $a{tags};
  $store->save_task($t);
}

mk( id => 1, title => 'Write documentation', status => 'todo',        priority => 'high', due => '2026-07-01' );
mk( id => 2, title => 'Review pull requests', status => 'in-progress', claimed_by => 'getty' );
mk( id => 3, title => 'Fix sync race',        status => 'in-progress', priority => 'critical',
    claimed_by => 'alice', blocked => 'waiting on libgit2' );
mk( id => 4, title => 'Ship v0.301',          status => 'done' );
mk( id => 5, title => 'Tagged task',          status => 'todo', tags => [qw( docs urgent )] );

sub render {
  my (%opt) = @_;
  local $ENV{NO_COLOR} = 1;
  my $cmd = App::karr::Cmd::Board->new( store => $store, %opt );
  my $buf = '';
  {
    local *STDOUT;

t/37-board-render.t  view on Meta::CPAN

  like $out, qr/^# My Board$/m,                        'board name as h1';
  like $out, qr/^## Todo$/m,                            'status header title-cased';
  like $out, qr/^## In Progress$/m,                     'kebab status -> "In Progress"';
  like $out, qr/^## Done$/m,                            'empty-ish section still shown';

  like $out, qr/^- 1 \| Write documentation \| priority:high \| due:2026-07-01$/m,
    'task line: id | title | priority | due';
  like $out, qr/^- 2 \| Review pull requests \| \@getty$/m,
    'claimed task shows @owner and omits default priority';
  unlike $out, qr/priority:medium/,                     'medium (default) priority is suppressed';
  like $out, qr/^- 3 \| Fix sync race \| priority:critical \| \@alice \| blocked:waiting on libgit2$/m,
    'blocked task shows reason';

  unlike $out, qr/#docs|#urgent/,                       'tags hidden without --tags';

  like $out, qr/^5 tasks/m,                             'footer counts tasks';
  like $out, qr/\bclaimed\b/,                           'footer mentions claimed';
  like $out, qr/\bblocked\b/,                           'footer mentions blocked';
};

subtest '--tags adds an extra tag line' => sub {
  my $out = render( tags => 1 );
  like $out, qr/^- 5 \| Tagged task$/m,                 'tagged task line unchanged';
  like $out, qr/^\s+#docs #urgent$/m,                   'tags on their own indented line';
};

subtest 'archived empty section is skipped' => sub {
  my $out = render();



( run in 1.667 second using v1.01-cache-2.11-cpan-2398b32b56e )