App-karr

 view release on metacpan or  search on metacpan

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

karr list --sort priority --reverse          # sort and reverse
karr list --claimed-by agent-1               # filter by claim owner
karr list --compact                          # one-line output (agent-friendly)
karr list --json                             # JSON output
```

### Show task

```bash
karr show ID
karr show                  # most recently updated task
karr show --last 5         # the 5 most recent
karr show --me             # the task you most recently acted on (re-orient)
karr show --agent NAME     # the task most recently claimed by NAME
```

### Move task

```bash
karr move ID STATUS                          # move to specific status
karr move ID --next                          # advance one status

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

## Stored task format

```markdown
---
id: 1
title: Set up CI pipeline
status: backlog
priority: high
class: standard
created: 2026-03-12T10:00:00Z
updated: 2026-03-12T10:00:00Z
tags:
  - devops
---

Optional body with more detail.
```

Tasks are stored under `refs/karr/tasks/*/data`. During command execution `karr`
materializes the same Markdown shape into a temporary task directory, so this
format still matters when reading or generating tasks programmatically.

Changes  view on Meta::CPAN

    - 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`);
      pre-existing bare-email logs are still read for the `user` role.
    - karr show: with no ID shows the single most recently updated task;
      `--last N` widens that, `--me` shows the task(s) the current identity
      most recently acted on (via the activity log), and `--agent NAME` shows
      the task(s) most recently claimed by that agent name.
    - karr board: hide the `@claimed_by` badge and claimed-count for tasks in
      a terminal status (done/archived) — a claim is an active lease, and the
      history remains in the activity log.
    - sync: surface the real libgit2 error on a failed pull/push instead of a
      meaningless "(exit code $?)" (native libgit2 operations have no shell
      exit code). New Git `last_error` accessor records the last remote-op
      exception.

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

use App::karr::Task;

my $content = <<'MD';
---
id: 42
title: Test from_string
status: todo
priority: high
class: standard
created: 2026-03-19T10:00:00Z
updated: 2026-03-19T10:00:00Z
---

This is the body.
MD

# Test from_string
my $task = App::karr::Task->from_string($content);
is $task->id, 42, 'from_string: id';
is $task->title, 'Test from_string', 'from_string: title';
is $task->status, 'todo', 'from_string: status';

docs/superpowers/plans/2026-03-22-docker-runtime-images.md  view on Meta::CPAN

- Modify: `README.md`
- Modify: `lib/App/karr.pm`
- Modify: `share/claude-skill.md`

- [ ] **Step 1: Update build hooks so local builds create both `latest` and `user` tags from the correct targets**
- [ ] **Step 2: Update release hooks so published images include the new `user` tag without introducing a UID-tag matrix**
- [ ] **Step 3: Document the recommended Docker alias for `latest` and explain when to use `:user`**
- [ ] **Step 4: Mention the dynamic ownership behavior in the main POD and keep Docker guidance aligned with the README**
- [ ] **Step 5: Refresh the bundled skill text if Docker usage guidance is referenced there**

### Task 4: Verify, install the updated skill, and commit

**Files:**
- Modify: repository working tree as needed

- [ ] **Step 1: Run `prove -l t`**
- [ ] **Step 2: Run `podchecker lib/App/karr.pm lib/App/karr/Cmd/*.pm lib/App/karr/*.pm lib/App/karr/Role/*.pm`**
- [ ] **Step 3: Run `perl -c` across the modified Perl files**
- [ ] **Step 4: Build both Docker targets locally and smoke-test ownership behavior where practical**
- [ ] **Step 5: Reinstall the Codex skill from `share/claude-skill.md` and create a commit with Codex trailers**

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

# 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


### `karr set-refs <ref> <content...>`

- Accepts either a bare ref suffix such as `superpowers/spec/1234.md` or a full
  ref name such as `refs/superpowers/spec/1234.md`.
- Normalizes bare values to `refs/...`.
- Joins all remaining positional arguments into the stored payload separated by
  single spaces.
- Writes status information to `stderr`.
- Writes nothing to `stdout` on success.
- Pushes exactly the updated ref to the configured remote.

### `karr get-refs <ref>`

- 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

docs/superpowers/specs/2026-03-22-ref-first-board-design.md  view on Meta::CPAN


### Write commands

Commands such as `create`, `edit`, `move`, `archive`, `delete`, `pick`,
`handoff`, and `config set` should follow this sequence:

1. fetch current refs
2. load canonical state from refs
3. apply the requested mutation
4. write only the affected refs
5. push updated refs

There should be no persistent repo-local board cache.

## Temporary materialization

The pragmatic implementation path is to keep task/config parsing formats but
move any file materialization into a temp area created per command execution.

That gives us:

docs/superpowers/specs/2026-05-15-role-boardaccess-split-design.md  view on Meta::CPAN

- Create `lib/App/karr/SyncGuard.pm`
- Modify `lib/App/karr/Role/BoardAccess.pm` — may be removed or kept as empty alias for backward compat
- Modify all 20+ command modules to compose `Role::BoardDiscovery` + `Role::SyncLifecycle`
- Modify `App::karr` (main app) to compose roles
- Update `.gitignore` to ensure `tasks/` is ignored
- Update `AGENTS.md` and `CLAUDE.md` to reflect new architecture

## Risks

- `sync_after` called twice if command calls it explicitly AND guard DESTROY runs — mitigated by `_done` flag
- Commands that currently read `.md` files directly need to be updated to use `$store->load_tasks()`

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

option claimed_by => (
  is => 'ro',
  format => 's',
  doc => 'Filter by claim owner',
);

option sort => (
  is => 'ro',
  format => 's',
  default => sub { 'id' },
  doc => 'Sort by: id, status, priority, created, updated, due',
);

option reverse => (
  is => 'ro',
  short => 'r',
  doc => 'Reverse sort order',
);

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

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

=item * C<--assignee>, C<--tag>, C<--claimed-by>

Limit the result set to a specific assignee, tag, or claim owner.

=item * C<-s>, C<--search>

Performs a case-insensitive substring search across title, body, and tags.

=item * C<--sort>, C<--reverse>

Sort by C<id>, C<status>, C<priority>, C<created>, C<updated>, or C<due>, and
optionally reverse the result order.

=back

=head1 SEE ALSO

L<karr>, L<App::karr>, L<App::karr::Cmd::Show>, L<App::karr::Cmd::Board>,
L<App::karr::Cmd::Create>, L<App::karr::Cmd::Pick>

=head1 SUPPORT

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

  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) = @_;
  return sort { ($b->updated // '') cmp ($a->updated // '') } @tasks;
}

# Task ids the current identity most recently acted on, newest first, deduped.
sub _my_recent_ids {
  my ($self, $limit) = @_;
  my @ids;
  my %seen;
  for my $entry (reverse $self->activity_log->entries) {
    my $tid = $entry->{task_id};
    next unless defined $tid;

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


  my $limit = $self->last > 0 ? $self->last : 1;

  if ($self->me) {
    my @tasks = grep { defined } map { $self->find_task($_) } $self->_my_recent_ids($limit);
    return @tasks;
  }

  if (defined $self->agent) {
    my @claimed = grep { $_->has_claimed_by && $_->claimed_by eq $self->agent } $self->load_tasks;
    my @sorted  = $self->_by_updated(@claimed);
    return splice(@sorted, 0, $limit);
  }

  my @sorted = $self->_by_updated($self->load_tasks);
  return splice(@sorted, 0, $limit);
}

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

  my @tasks = $self->_select_tasks($args_ref->[0]);

  unless (@tasks) {
    print "No tasks found.\n" unless $self->json;

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


App::karr::Cmd::Show - Show full details of a task

=head1 VERSION

version 0.303

=head1 SYNOPSIS

    karr show 12              # a specific task
    karr show                 # the most recently updated task
    karr show --last 5        # the 5 most recently updated tasks
    karr show --me            # the last task my identity acted on
    karr show --agent fox-owl # the last task claimed by that agent
    karr show 12 --json

=head1 DESCRIPTION

Shows the full details of a task, including optional metadata such as tags, due
date, estimate, claim state, and the Markdown body. This is the most complete
human-readable view of an individual card.

With no C<ID>, shows the most recently updated task. C<--last N> widens that to
the C<N> most recently updated. C<--me> instead resolves the task(s) the
current identity most recently acted on (via the activity log). C<--agent NAME>
shows the task(s) most recently claimed by that agent name. C<ID> always wins
over the selector options.

=head1 SEE ALSO

L<karr>, L<App::karr>, L<App::karr::Cmd::List>, L<App::karr::Cmd::Edit>,
L<App::karr::Cmd::Move>, L<App::karr::Cmd::Archive>, L<App::karr::Cmd::Log>

=head1 SUPPORT

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

      printf "%-12s not installed (run 'karr skill install' first)\n", $agent unless $self->json;
      next;
    }

    my $installed = $file->slurp_utf8;
    if ($installed eq $content) {
      push @results, { agent => $agent, status => 'current' };
      printf "%-12s already current\n", $agent unless $self->json;
    } else {
      $file->spew_utf8($content);
      push @results, { agent => $agent, status => 'updated' };
      printf "%-12s updated\n", $agent unless $self->json;
    }
  }

  if ($self->json) {
    $self->print_json(\@results);
  }
}

sub _target_agents {
  my ($self) = @_;

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

# (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 ) {

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

# tasks that count toward an auto-block.
sub _stuck_tasks {
  my ( $self, $before, $after ) = @_;
  my @stuck;
  for my $id ( sort { $a <=> $b } keys %$after ) {
    my $a = $after->{$id};
    next unless $self->_is_actionable( $a );
    next unless defined $a->{claimed_by} || ( $a->{status} // '' ) eq 'in-progress';
    my $b = $before->{$id} or next;   # newly created this run — give it grace
    next if ( $b->{status}  // '' ) ne ( $a->{status}  // '' );
    next if ( $b->{updated} // '' ) ne ( $a->{updated} // '' );
    push @stuck, $id;
  }
  return @stuck;
}

# ---------------------------------------------------------------------------
# Drain loop
# ---------------------------------------------------------------------------

# Run the agent repeatedly until the board has no actionable tasks left,

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

has priority   => ( is => 'rw', default => sub { 'medium' } );
has assignee   => ( is => 'rw', predicate => 1, clearer => 1 );
has tags       => ( is => 'rw', default => sub { [] } );
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

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

}

sub to_frontmatter {
  my ($self) = @_;
  my %fm = (
    id       => $self->id,
    title    => $self->title,
    status   => $self->status,
    priority => $self->priority,
    created  => $self->created,
    updated  => $self->updated,
    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;

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


sub from_file {
  my ($class, $file) = @_;
  $file = path($file);
  my ($fm, $body) = $class->_parse_content($file->slurp_utf8);
  return $class->new(%$fm, body => $body, file_path => $file);
}

sub save {
  my ($self, $dir) = @_;
  $self->updated(gmtime->datetime . 'Z');
  my $file = $dir ? path($dir)->child($self->filename) : path($self->file_path);
  $file->spew_utf8($self->to_markdown);
  $self->file_path($file);
  return $file;
}

1;

__END__

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

## Stored task format

```markdown
---
id: 1
title: Set up CI pipeline
status: backlog
priority: high
class: standard
created: 2026-03-12T10:00:00Z
updated: 2026-03-12T10:00:00Z
tags:
  - devops
---

Optional body with more detail.
```

Tasks are stored under `refs/karr/tasks/*/data`. During command execution `karr`
materializes the same Markdown shape into a temporary task directory, so this
format still matters when reading or generating tasks programmatically.

t/06-config-cmd.t  view on Meta::CPAN

  my $file = path($dir)->child('config.yml');
  DumpFile($file->stringify, App::karr::Config->default_config);

  my $config = App::karr::Config->new(file => $file);
  is $config->claim_timeout, '1h', 'default timeout';

  $config->data->{claim_timeout} = '30m';
  $config->save;

  my $config2 = App::karr::Config->new(file => $file);
  is $config2->claim_timeout, '30m', 'timeout updated';
};

# Regression: `karr config show` crashed with
#   Can't locate object method "board_dir" via package "App::karr::Cmd::Config"
# because the command read its config from $self->board_dir->child('config.yml')
# instead of the ref-first store. These subtests drive the real command via a
# mock store so `show`/`get`/`set` no longer reference the dead board_dir method.
subtest 'config show runs through the command (no board_dir)' => sub {
  my $cmd = App::karr::Cmd::Config->new( store => MockStore->new );
  my $out;

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

  $file->spew_utf8("# My Agents\n\n" . $context);

  # Update with new context
  my $new_context = "<!-- BEGIN kanban-md context -->\n## Board: Updated\n<!-- END kanban-md context -->\n";
  my $content = $file->slurp_utf8;
  $content =~ s/<!-- BEGIN kanban-md context -->.*<!-- END kanban-md context -->\n?/$new_context/s;
  $file->spew_utf8($content);

  my $result = $file->slurp_utf8;
  like $result, qr/# My Agents/, 'preserves existing content';
  like $result, qr/Board: Updated/, 'updated context';
  unlike $result, qr/Board: Test/, 'old context replaced';
};

# Regression: karr context crashed with
#   Can't locate object method "strftime" via package "Sun May 24 ..."
# because Cmd::Context never did `use Time::Piece`, so gmtime returned a
# plain string in the overdue / recently-completed / _count_overdue paths.
# These subtests drive the real command through every gmtime->strftime branch.
subtest 'context command runs without strftime crash' => sub {
  my $today  = gmtime->strftime('%Y-%m-%d');

t/14-task-parse.t  view on Meta::CPAN

use App::karr::Task;

my $content = <<'MD';
---
id: 42
title: Test from_string
status: todo
priority: high
class: standard
created: 2026-03-19T10:00:00Z
updated: 2026-03-19T10:00:00Z
---

This is the body.
MD

# Test from_string
my $task = App::karr::Task->from_string($content);
is $task->id, 42, 'from_string: id';
is $task->title, 'Test from_string', 'from_string: title';
is $task->status, 'todo', 'from_string: status';

t/14-task-parse.t  view on Meta::CPAN


# Test content without body
my $no_body = <<'MD';
---
id: 99
title: No body task
status: backlog
priority: low
class: standard
created: 2026-03-19T10:00:00Z
updated: 2026-03-19T10:00:00Z
---
MD

my $nb = App::karr::Task->from_string($no_body);
is $nb->id, 99, 'no-body task: id';
is $nb->body, '', 'no-body task: empty body';

done_testing;

t/24-ref-first-board-access.t  view on Meta::CPAN

    # Write a task directly to refs
    $git->write_ref( 'refs/karr/config', Dump({ version => 1 }) );
    $git->write_ref( 'refs/karr/meta/next-id', "1\n" );
    my $task_yaml = Dump({
        id => 1,
        title => 'Test task',
        status => 'backlog',
        priority => 'high',
        class => 'standard',
        created => '2026-05-15T10:00:00Z',
        updated => '2026-05-15T10:00:00Z',
    });
    $git->write_ref( 'refs/karr/tasks/1/data', "---\n$task_yaml\n---\n\nTest body" );

    my $board = TestBoard->new( dir => $repo );

    my @tasks = $board->load_tasks;
    is( scalar @tasks, 1, 'load_tasks returns one task' );
    is( $tasks[0]->id, 1, 'task id is correct' );
    is( $tasks[0]->title, 'Test task', 'task title is correct' );

t/30-foundation.t  view on Meta::CPAN

  my $dir = tempdir( CLEANUP => 1 );
  my $f   = new_foundation();

  is $f->_state_get( $dir, 'hash' ), undef, 'undef before any state written';

  $f->_state_set( $dir, hash => 'abc123', last_exit => 0 );
  is $f->_state_get( $dir, 'hash' ),      'abc123', 'hash persisted';
  is $f->_state_get( $dir, 'last_exit' ), 0,        'last_exit persisted';

  $f->_state_set( $dir, hash => 'def456' );
  is $f->_state_get( $dir, 'hash' ),      'def456', 'hash updated';
  is $f->_state_get( $dir, 'last_exit' ), 0,        'last_exit preserved on partial update';
};

# ---------------------------------------------------------------------------
# .karr file parsing
# ---------------------------------------------------------------------------

subtest '_load_karr: missing file → empty hash' => sub {
  my $dir = tempdir( CLEANUP => 1 );
  my $f   = new_foundation();

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

  is $f->_match_error( "something kaboom", $cp ), 'kaboom', 'custom pattern matches';
};

# ---------------------------------------------------------------------------
# Unit: stuck-task detection
# ---------------------------------------------------------------------------

subtest '_stuck_tasks' => sub {
  my $f = App::karr::Foundation->new;
  my $before = {
    1 => { status => 'in-progress', claimed_by => 'a', updated => 'T1' },
    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;

t/32-show.t  view on Meta::CPAN

  system( 'git', '-C', $repo, 'config', 'user.name', 'Test User' );
  return $repo;
}

my $repo  = _init_repo();
my $git   = App::karr::Git->new( dir => $repo );
$git->write_ref( 'refs/karr/config', Dump( { version => 1, board => { name => 'T' } } ) );
$git->write_ref( 'refs/karr/meta/next-id', "9\n" );
my $store = App::karr::BoardStore->new( git => $git );

# Three tasks, deterministic 'updated' so recency ordering is stable.
my %updated = (
  1 => '2026-01-01T00:00:00Z',
  2 => '2026-03-01T00:00:00Z',
  3 => '2026-02-01T00:00:00Z',
);
for my $id ( 1, 2, 3 ) {
  my $t = App::karr::Task->new(
    id       => $id,
    title    => "Task $id",
    status   => 'backlog',
    priority => 'medium',
    class    => 'standard',
  );
  $t->updated( $updated{$id} );
  $t->claimed_by('fox-owl') if $id == 3;
  $store->save_task($t);
}

my $ids = sub { map { $_->id } @_ };

subtest 'explicit id wins over selectors' => sub {
  my $cmd = App::karr::Cmd::Show->new( store => $store, me => 1, last => 5 );
  my @t = $cmd->_select_tasks(1);
  is_deeply [ $ids->(@t) ], [1], 'returns exactly the requested task';
};

subtest 'no id, default last=1 -> most recently updated' => sub {
  my $cmd = App::karr::Cmd::Show->new( store => $store );
  my @t = $cmd->_select_tasks(undef);
  is_deeply [ $ids->(@t) ], [2], 'task 2 is newest by updated';
};

subtest '--last 2 -> two newest, descending' => sub {
  my $cmd = App::karr::Cmd::Show->new( store => $store, last => 2 );
  my @t = $cmd->_select_tasks(undef);
  is_deeply [ $ids->(@t) ], [ 2, 3 ], 'two newest in updated-desc order';
};

subtest '--agent filters by claimed_by' => sub {
  my $cmd = App::karr::Cmd::Show->new( store => $store, agent => 'fox-owl', last => 5 );
  my @t = $cmd->_select_tasks(undef);
  is_deeply [ $ids->(@t) ], [3], 'only the task claimed by fox-owl';
};

subtest '--me resolves via the activity log identity' => sub {
  # Two entries for this identity; task 1 is the most recent action.

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

subtest 'an explicit null in a loaded file is normalized to unset' => sub {
  # Older karr writes and external kanban-md edits may carry explicit nulls.
  my $legacy = App::karr::Task->from_string(<<'MD');
---
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';
};



( run in 1.655 second using v1.01-cache-2.11-cpan-bbb979687b5 )