App-karr

 view release on metacpan or  search on metacpan

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

  lib/App/karr/Cmd/Delete.pm lib/App/karr/Cmd/Archive.pm lib/App/karr/Cmd/Handoff.pm \
  lib/App/karr/Cmd/Pick.pm
git commit -m "refactor: replace _sync_after with role sync_before/sync_after"
```

---

## Chunk 3: Lock + Pick

### Task 7: Refactor Lock.pm to accept Git object

**Files:**
- Modify: `lib/App/karr/Lock.pm`

- [ ] **Step 1: Refactor Lock.pm constructor**

```perl
# ABSTRACT: Lock management via Git refs

package App::karr::Lock;

use strict;
use warnings;

sub new {
    my ( $class, %args ) = @_;
    my $git = $args{git};
    unless ($git) {
        require App::karr::Git;
        $git = App::karr::Git->new( dir => $args{dir} // '.' );
    }
    return bless {
        git     => $git,
        task_id => $args{task_id},
    }, $class;
}

sub task_id { shift->{task_id} }
sub git     { shift->{git} }

sub ref_name {
    my ( $self, $task_id ) = @_;
    $task_id //= $self->task_id;
    return "refs/karr/tasks/$task_id/lock";
}

sub get {
    my ( $self, $task_id ) = @_;
    my $ref = $self->ref_name($task_id);
    my $content = $self->git->read_ref($ref);
    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**

Run: `prove -l t/11-git-impl.t t/13-git-refs.t`
Expected: PASS

- [ ] **Step 3: Commit**

```bash
git add lib/App/karr/Lock.pm
git commit -m "refactor: Lock accepts pre-built Git object"
```

---

### Task 8: Rewrite Pick.pm with Lock integration

**Files:**
- Modify: `lib/App/karr/Cmd/Pick.pm`
- Test: `t/16-pick-lock.t`

- [ ] **Step 1: Write test for Pick claiming different tasks sequentially**

```perl
# t/16-pick-lock.t
use strict;
use warnings;
use Test::More;
use File::Temp qw( tempdir );
use Path::Tiny;
use YAML::XS qw( DumpFile );
use App::karr::Task;
use App::karr::Git;
use App::karr::Lock;

# Set up a git repo with a karr board
my $repo = tempdir( CLEANUP => 1 );
system("git init '$repo' 2>/dev/null");
system("git -C '$repo' config user.email 'test\@test.com'");
system("git -C '$repo' config user.name 'Test'");

my $board = path($repo)->child('karr');
$board->mkpath;
$board->child('tasks')->mkpath;

my $config = {
    version => 1,
    board => { name => 'Test' },
    tasks_dir => 'tasks',
    statuses => ['backlog', 'todo', 'in-progress', 'done', 'archived'],
    priorities => ['low', 'medium', 'high', 'critical'],
    next_id => 3,
    claim_timeout => '1h',
    defaults => { status => 'backlog', priority => 'medium', class => 'standard' },
};
DumpFile($board->child('config.yml')->stringify, $config);

# Create two tasks
for my $i (1, 2) {
    App::karr::Task->new(
        id => $i, title => "Task $i", status => 'todo',
        priority => 'high', class => 'standard',
    )->save($board->child('tasks'));
}

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
my ($ok5, $msg5) = $lock->acquire(1, 'agent-b@test.com');
ok $ok5, 'agent B can now lock task 1 after release';

# Clean up
$lock->release(1, 'agent-b@test.com');
$lock->release(2, 'agent-b@test.com');

done_testing;
```

- [ ] **Step 2: Run test**

Run: `prove -l t/16-pick-lock.t`
Expected: PASS

- [ ] **Step 3: Rewrite Pick.pm with Lock integration**

Replace Pick.pm's execute method to use Lock before claiming. The lock acquire/release wraps the claim operation. In non-repo contexts (no git), skip locking.

Key changes to `execute`:
- After filtering/sorting tasks, loop with lock acquisition
- Use `$self->sync_before` at start instead of `_sync_after`
- After save, call `$self->sync_after`
- Lock acquire before claim, release after sync_after

```perl
sub execute {
    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;
    my $lock;
    if ($use_lock) {
        require App::karr::Lock;
        $lock = App::karr::Lock->new(git => $git);
    }
    my $email = $use_lock ? ($git->git_user_email || $self->claim) : $self->claim;

    my $picked;
    for my $task (@tasks) {
        if ($use_lock) {
            my ($ok, $msg) = $lock->acquire($task->id, $email);
            next unless $ok;
        }

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

        if ($self->move) {
            $task->status($self->move);
            if ($self->move eq 'in-progress' && !$task->has_started) {
                $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,
            action  => 'pick',
            task_id => $picked->id,
            detail  => $picked->status,
        );
    }

    # Release lock AFTER sync is complete
    if ($use_lock) {
        $lock->release($picked->id, $email);
        $git->push('origin', $lock->ref_name($picked->id) . ':' . $lock->ref_name($picked->id));
    }

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

    printf "Picked task %d: %s (claimed by %s)\n", $picked->id, $picked->title, $self->claim;
    printf "Status: %s | Priority: %s | Class: %s\n", $picked->status, $picked->priority, $picked->class;
    if ($picked->body) {
        print "\n" . $picked->body . "\n";
    }
}
```

- [ ] **Step 4: Run all tests**

Run: `prove -l t/`
Expected: All pass

- [ ] **Step 5: Commit**

```bash
git add lib/App/karr/Cmd/Pick.pm t/16-pick-lock.t
git commit -m "feat: Pick uses Lock for atomic task claiming"
```

---

## Chunk 4: Log + List Filter

### Task 9: Add `--claimed-by` filter to List

**Files:**
- Modify: `lib/App/karr/Cmd/List.pm`
- Test: `t/18-list-filter.t`

- [ ] **Step 1: Write failing test**



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