App-karr
view release on metacpan or search on metacpan
docs/superpowers/plans/2026-03-19-v0004-implementation.md view on Meta::CPAN
# karr v0.004 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix Git ref sync to store task data as commit-wrapped blobs, add activity log, harden multi-agent coordination, and prepare for release.
**Architecture:** All git operations move to a safe `_git_cmd` helper (fork+exec, no shell interpolation). Refs wrap content in blobâtreeâcommit so `git push/fetch` works. Sync materializes refs to local files and serializes back. Lock integrates...
**Tech Stack:** Perl 5, Moo, MooX::Cmd, MooX::Options, YAML::XS, Path::Tiny, JSON::MaybeXS, IPC::Open2, Time::Piece
**Spec:** `docs/superpowers/specs/2026-03-19-v0004-release-design.md`
---
## File Structure
### Modified Files
| File | Responsibility | Changes |
|------|---------------|---------|
| `lib/App/karr/Git.pm` | Git CLI wrapper | Full rewrite: `_git_cmd`, `_git_cmd_stdin`, commit-wrapped `write_ref`/`read_ref`, `save_task_ref`, `load_task_ref`, `list_task_refs`, fixed `is_repo`/`push`/`pull` |
| `lib/App/karr/Task.pm` | Task model | Refactor: `_parse_content`, `from_string`, refactored `from_file` |
| `lib/App/karr/Lock.pm` | Task locking | Refactor: accept `git` object instead of creating own |
| `lib/App/karr/Role/BoardAccess.pm` | Shared board ops | Add: `sync_before`, `sync_after`, `_materialize_from_refs`, `_serialize_to_refs`, `append_log` |
| `lib/App/karr/Cmd/Sync.pm` | Sync command | Rewrite: full sync with materialization |
| `lib/App/karr/Cmd/Pick.pm` | Pick command | Rewrite: Lock integration with immediate push |
| `lib/App/karr/Cmd/List.pm` | List command | Add: `--claimed-by` filter |
| `lib/App/karr/Cmd/Init.pm` | Init command | Remove `.gitignore` manipulation |
| `lib/App/karr/Cmd/Create.pm` | Create command | Remove `_sync_after`, use role's `sync_before`/`sync_after` |
| `lib/App/karr/Cmd/Move.pm` | Move command | Same |
| `lib/App/karr/Cmd/Edit.pm` | Edit command | Same |
| `lib/App/karr/Cmd/Delete.pm` | Delete command | Same |
| `lib/App/karr/Cmd/Archive.pm` | Archive command | Same |
| `lib/App/karr/Cmd/Handoff.pm` | Handoff command | Remove `_sync_after`, `_parse_timeout`, `_claim_expired`; use shared role |
| `lib/App/karr/Role/ClaimTimeout.pm` | Shared claim timeout logic | New: `_parse_timeout`, `_claim_expired` consumed by Pick + Handoff |
| `Dockerfile` | Docker image | Add git identity ENV vars |
| `Changes` | Changelog | Remove "experimental", add v0.004 entries |
### New Files
| File | Responsibility |
|------|---------------|
| `lib/App/karr/Cmd/Log.pm` | Activity log command |
| `t/13-git-refs.t` | Git ref roundtrip tests (commit-wrapped write/read) |
| `t/14-task-parse.t` | `from_string`/`from_file` parity tests |
| `t/15-sync.t` | Materialize/serialize roundtrip tests |
| `t/16-pick-lock.t` | Pick with Lock integration test |
| `t/17-log.t` | Log append + read tests |
| `t/18-list-filter.t` | `--claimed-by` filter test |
| `t/19-git-push-fetch.t` | Push/fetch between two repos test |
| `t/20-next-id-collision.t` | Config next_id collision prevention test |
---
## Chunk 1: Git.pm Foundation
All other changes depend on Git.pm having safe execution and commit-wrapped refs.
### Task 1: Rewrite Git.pm with safe execution
**Files:**
- Modify: `lib/App/karr/Git.pm`
- Test: `t/13-git-refs.t`
- [ ] **Step 1: Write failing test for `_git_cmd`**
```perl
# t/13-git-refs.t
use strict;
use warnings;
use Test::More;
use File::Temp qw( tempdir );
use App::karr::Git;
# Create a temporary git repo
my $dir = tempdir( CLEANUP => 1 );
system("git init '$dir' 2>/dev/null");
system("git -C '$dir' config user.email 'test\@test.com'");
system("git -C '$dir' config user.name 'Test'");
my $git = App::karr::Git->new( dir => $dir );
# Test is_repo
ok $git->is_repo, 'detects git repo';
# Test is_repo from subdirectory
my $subdir = "$dir/karr";
mkdir $subdir;
my $sub_git = App::karr::Git->new( dir => $subdir );
ok $sub_git->is_repo, 'detects git repo from subdirectory';
# Test non-repo
my $non_repo = tempdir( CLEANUP => 1 );
my $non_git = App::karr::Git->new( dir => $non_repo );
ok !$non_git->is_repo, 'non-repo returns false';
docs/superpowers/plans/2026-03-19-v0004-implementation.md view on Meta::CPAN
Expected: All pass
- [ ] **Step 3: Commit**
```bash
git add lib/App/karr/Cmd/Sync.pm
git commit -m "feat: rewrite Sync.pm with full materialization"
```
---
### Task 6: Remove `_sync_after` from 7 commands, use role methods
**Files:**
- Modify: `lib/App/karr/Cmd/Create.pm`
- Modify: `lib/App/karr/Cmd/Move.pm`
- Modify: `lib/App/karr/Cmd/Edit.pm`
- Modify: `lib/App/karr/Cmd/Delete.pm`
- Modify: `lib/App/karr/Cmd/Archive.pm`
- Modify: `lib/App/karr/Cmd/Handoff.pm`
- Modify: `lib/App/karr/Cmd/Pick.pm` (Pick gets full rewrite in Task 8, just remove _sync_after for now)
For each of the 7 commands, the change is identical:
1. Delete the `_sync_after` sub
2. Replace `$self->_sync_after if -d '.git';` at start of `execute` with `$self->sync_before;`
3. Add `$self->sync_after;` before return at end of `execute`
**Note on logging:** Commands that modify tasks should call `$self->append_log(...)` between their operation and `sync_after`. For example, in Create.pm after `$task->save`:
```perl
$task->save($self->tasks_dir);
# Log the action
my $git = App::karr::Git->new(dir => $self->board_dir->parent->stringify);
$self->append_log($git, agent => 'user', action => 'create', task_id => $task->id, detail => $task->status) if $git->is_repo;
$self->sync_after;
```
The same pattern applies to Move, Edit, Delete, Archive, Handoff. The `agent` field should use `$self->claim` if available, or `$git->git_user_email`, or `'user'` as fallback.
- [ ] **Step 1: Update Create.pm**
Remove `_sync_after` sub (lines 70-77). Replace line 83 `$self->_sync_after if -d '.git';` with `$self->sync_before;`. Add `$self->sync_after;` after the printf at line 110.
- [ ] **Step 2: Update Move.pm**
Remove `_sync_after` sub (lines 33-40). Replace line 46 with `$self->sync_before;`. Add `$self->sync_after;` before the json output block.
- [ ] **Step 3: Update Edit.pm**
Remove `_sync_after` sub (lines 93-100). Replace line 106 with `$self->sync_before;`. Add `$self->sync_after;` before the json output block.
- [ ] **Step 4: Update Delete.pm**
Remove `_sync_after` sub (lines 22-29). Replace line 35 with `$self->sync_before;`. Add `$self->sync_after;` before the json output block.
- [ ] **Step 5: Update Archive.pm**
Remove `_sync_after` sub (lines 16-23). Replace line 29 with `$self->sync_before;`. Add `$self->sync_after;` before the json output block.
- [ ] **Step 6a: Create ClaimTimeout role**
Create `lib/App/karr/Role/ClaimTimeout.pm` to share `_parse_timeout` and `_claim_expired` between Pick and Handoff:
```perl
# ABSTRACT: Shared claim timeout logic
package App::karr::Role::ClaimTimeout;
use Moo::Role;
use Time::Piece;
sub _parse_timeout {
my ($self, $timeout_str) = @_;
return 3600 unless $timeout_str;
if ($timeout_str =~ /^(\d+)h$/) { return $1 * 3600; }
if ($timeout_str =~ /^(\d+)m$/) { return $1 * 60; }
return 3600;
}
sub _claim_expired {
my ($self, $task, $timeout_secs) = @_;
return 0 unless $task->has_claimed_at;
my $claimed = eval { Time::Piece->strptime($task->claimed_at =~ s/Z$//r, '%Y-%m-%dT%H:%M:%S') };
return 0 unless $claimed;
return (gmtime() - $claimed) > $timeout_secs;
}
1;
```
- [ ] **Step 6b: Update Handoff.pm**
Remove `_sync_after` sub. Remove `_parse_timeout` and `_claim_expired`. Add `with 'App::karr::Role::ClaimTimeout';` to the `with` line (alongside BoardAccess and Output). Replace sync call with `$self->sync_before;`. Add `$self->sync_after;` after sa...
- [ ] **Step 7: Update Pick.pm (minimal â full rewrite in Task 8)**
Remove `_sync_after` sub. Remove `_parse_timeout` and `_claim_expired`. Add `with 'App::karr::Role::ClaimTimeout';`. Replace sync call with `$self->sync_before;`. Add `$self->sync_after;` after the json output block.
- [ ] **Step 8: Run all tests**
Run: `prove -l t/`
Expected: All pass
- [ ] **Step 9: Commit**
```bash
git add lib/App/karr/Cmd/Create.pm lib/App/karr/Cmd/Move.pm lib/App/karr/Cmd/Edit.pm \
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);
( run in 2.215 seconds using v1.01-cache-2.11-cpan-5837b0d9d2c )