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 )