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 )