App-karr

 view release on metacpan or  search on metacpan

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


```bash
karr edit ID --title "New title"
karr edit ID --priority high --add-tag urgent
karr edit ID --add-depends-on 2,3            # append dependency ids (no duplicates; ids must exist, no self-reference)
karr edit ID --remove-depends-on 4           # absent ids are a no-op (cleanup after a deleted dependency)
karr edit ID --body "New description"
karr edit ID -a "Appended note"              # append to body
karr edit ID --claim agent-1                 # claim
karr edit ID --release                       # release claim
karr edit ID --block "Waiting on API"        # mark blocked
karr edit ID --unblock                       # clear blocked
```

An unknown or non-numeric id given to `--depends-on`/`--add-depends-on` rejects
the whole invocation before anything is written (usage error, exit 2); a
self-reference (`karr edit 5 --add-depends-on 5`) fails only that id, the rest
of the batch proceeds, and the command exits 1. Taking up a card whose
dependencies are unfinished warns on move/pick but is never blocked.

### Delete task

```bash
karr delete ID --yes                         # skip confirmation
```

### Archive task

```bash

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

Shows tasks grouped by status with WIP utilization.

### Pick next task (multi-agent)

```bash
karr pick --claim agent-1                    # pick highest priority available
karr pick --claim agent-1 --status todo --move in-progress
karr pick --claim agent-1 --tags backend
```

Atomically finds and claims the next available task. Respects claim timeouts, blocked state, and class-of-service priority ordering (expedite > fixed-date > standard > intangible).

### Unlock a stuck task

```bash
karr unlock                                  # list the pick locks currently held
karr unlock ID                               # break one
karr unlock --all                            # break all of them
```

`karr pick` takes a lock ref and gives it back inside the same command, so normally there is nothing here to see. An agent that dies mid-pick leaves one behind. Locks expire on their own after `lock_timeout` (default `5m`, board config); this is how ...

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

karr config get foundation.enabled           # -> 0 or 1
karr config set foundation.enabled false     # true/false, yes/no, on/off, 1/0
karr config set foundation.reason "why"
```

### Context (board summary for embedding)

```bash
karr context                                 # print markdown summary
karr context --write-to AGENTS.md            # create/update file with sentinels
karr context --sections blocked,overdue      # filter sections
karr context --days 14                       # lookback for recently-completed
karr context --json                          # JSON output
```

Generates a markdown summary with sections: In Progress, Blocked, Overdue, Recently Completed. Uses `<!-- BEGIN kanban-md context -->` / `<!-- END kanban-md context -->` sentinels for in-place updates.

### Skill management

```bash
karr skill install                           # install skill for detected agents
karr skill install --agent claude-code       # install for specific agent
karr skill install --global                  # install globally (~/)
karr skill install --force                   # force reinstall
karr skill check                             # check if installed skills are current
karr skill update                            # update outdated skills

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

is kept separately in `refs/karr/meta/next-id`.

## Decision tree: which command?

1. **Need a board?** → `karr init`
2. **New work item?** → `karr create "Title" --priority high`
3. **What's on the board?** → `karr board` or `karr list`
4. **Starting work?** → `karr pick --claim NAME --move in-progress`
5. **Done with task, hand to review?** → `karr handoff ID --claim NAME --note "reason"`
6. **Done with task, close it?** → `karr edit ID --release && karr move ID done`
7. **Blocked?** → `karr edit ID --block "reason"`
8. **Need details?** → `karr show ID`
9. **Soft-delete?** → `karr archive ID`
10. **Board snapshot for agent context?** → `karr context --write-to AGENTS.md`
11. **Check/change config?** → `karr config` / `karr config set KEY VALUE`
12. **Install agent skills?** → `karr skill install`
13. **Need a full board snapshot?** → `karr backup` / `karr restore --yes`
14. **Need shared non-task workflow data?** → `karr set-refs` / `karr get-refs`
15. **Board should never be drained by an automation host?** → `karr disable --reason "why"`
16. **Need to remove the board completely?** → `karr destroy --yes`

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

karr config set foundation.reason "why"
```

`karr disable` without `--reason` clears any previously stored reason. When
every discovered board is disabled (or has no agent), `karr-foundation` falls
back to the overview instead of draining.

## Overview

`karr-foundation --status` (and the default when no board has an agent) prints a
read-only dashboard of every board: status counts, in-progress/blocked tasks,
and disabled/lock/cooldown state. No agent is run — usable by a human to
coordinate work.

```
dbio-informix
  7 tasks  [disabled]
  backlog:5  review:2
  disabled:    abandoned driver, backlog parked
```

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

`.karr.log` and otherwise ignored. The default patterns are narrow to match: a
symptom word counts next to a failure word on the same line (`network error`,
`invalid credentials`, `quota exceeded`), and an HTTP status only where
something adjacent marks it as one (`API error: 429`, `429 Too Many Requests`)
— not in a diffstat, a byte count or a line number. Before that, an agent
printing its own board tripped the scan on a backlog title and throttled a
healthy board to one run per hour (#160).

### Auto-block

When a task is stuck after `max_attempts`, foundation marks it blocked with:
```
blocked: auto-block: no progress after N attempts (foundation)
```
Agent can override with `karr edit --block "reason"`.

**Engaged** means foundation can prove the agent worked that card during *this*
drain: it runs the command with `KARR_ROLE=agent`, so the agent's `karr` writes
land in the board's activity log under the `agent` identity, and only tasks
named there — unclaimed, or held under a claim name the agent itself wrote
with — can be penalized. A card somebody else holds is never auto-blocked,
nor is one the agent merely left claimed in an earlier run (that is what
`claim_timeout` and `karr unlock` are for). Without that evidence — an agent
command that never calls `karr` — foundation auto-blocks **nothing** rather
than guess (#158).

### Exponential cooldown

On common-error: repo waits `cooldown_base × 2^level` minutes (capped at `cooldown_max`).
Level resets on next clean (non-error) run, which also drops `last_error` from
`.karr.state` — it describes the last run, not a past one.

CONTEXT.md  view on Meta::CPAN

[--reason]`). Board state, not machine state — it syncs, so every foundation
instance on every machine honours it, unlike the local `.karr` file. The opt-out
is absolute: checked before agent-command resolution and before the drain
decision, so it wins over `--command`, `default_command`, the `.karr` `command`
and `claude: true`, and `--force` does not override it. A parked backlog stays
fully usable by hand; only automation is switched off.

**Overview**:
Foundation's read-only dashboard (`--status` / `--overview`, or the default when
no agent is configured) — per board: status counts and what is
in-progress/claimed/blocked, plus which repos are locked (agent running) or in
cooldown. Fires no agent.

**Context block**:
The sentinel-delimited board summary (`karr context --write-to`) maintained
inside a host file such as `AGENTS.md`. karr deliberately writes kanban-md's
markers (`<!-- BEGIN kanban-md context -->`) so both tools can update the same
block and switching tools leaves no orphaned markers — an interop decision,
not a branding leftover.
_Avoid_: renaming the sentinels to "karr" (breaks cross-tool round-trips).

Changes  view on Meta::CPAN

      the full log (the bound silently removed) and `karr log --last -3`
      reported an empty log and exited 0 — indistinguishable from a board
      with no activity. `Cmd/Archive.pm:55` read `$pos[0] under `or die`,
      so the truthy comma in `karr archive ,` passed the guard, parse_ids
      split to nothing, and run_batch iterated zero items with no output
      and exit 0. `Cmd/{Edit,Create,Handoff}.pm` carried 17 sibling
      options whose presence was tested with `if ($self->foo)` rather
      than `defined && length`, so the literal value `0` was
      indistinguishable from "not given" — the write still ran, `updated`
      was bumped, an activity-log entry was appended, the command printed
      success, and `--block 0` left the card unblocked (the sharp edge:
      `karr pick` would have handed it out). The fix is the rule already
      written down for `--body` in ticket #78 (`defined && length`)
      applied to the siblings; `--last < 1` raises a usage error matching
      `Show.pm:161-162` and `Context.pm:97-99` exactly (same exit 2,
      same error format); `karr archive ,` raises the same usage error
      as `move ,` / `edit ,` / `delete ,` already do. The audit trail no
      longer records edits that did not happen.

    - `karr-foundation` now keeps an agent it started alive in three
      situations where it used to silently lose it: a pipeline/`&`/shell-

Changes  view on Meta::CPAN

      behind a refusal that only half succeeded (now reported), and the
      board-identity stamp the mirror could not record.

    - karr-foundation no longer auto-blocks tasks its agent never touched
      (ticket #158). `_stuck_tasks` claimed to return "tasks the agent engaged
      (claimed / in-progress) but did not move" and tested only whether the
      card carried *any* claim or sat in `in-progress` — who held it was never
      compared against anything. Every drain iteration in which the agent moved
      some other card therefore charged an attempt against every card somebody
      else was holding, and since `max_attempts` (default 2) can be spent
      inside a single drain, a human's in-progress card was blocked with
      `auto-block: no progress after N attempts (foundation)` and pushed to the
      remote within seconds — a destructive write to shared board state about
      work foundation never attempted, dropping that card out of `karr pick`'s
      actionable set behind its owner's back and giving a reason that is
      factually wrong. Engagement is now proven rather than assumed: foundation
      runs the agent with `KARR_ROLE=agent`, so the agent's `karr` writes are
      recorded in the board's own activity log under the `agent` identity, and
      only cards named there during that drain — held by nobody, or under a
      claim name the agent itself wrote with — can be penalized. A card the
      agent merely left claimed in an earlier run no longer counts either; a

Changes  view on Meta::CPAN

      with the reason in last_error. Requires Git::Native 0.005 and
      Git::Libgit2 0.006.
    - Fixed the em dash literals that reached users double-encoded (ticket
      #108). No file under lib/ or bin/ says `use utf8`, deliberately: non-ASCII
      belongs in data, and App::karr::Encoding owns every character/octet
      crossing. Eleven string literals in executable code carried a pasted em
      dash anyway, so Perl read its three bytes as three Latin-1 characters and
      the `:encoding(UTF-8)` layer encoded each of them again — the user saw a
      stray a-circumflex and two control characters where a dash belonged. The
      worst was `karr context`, which renders one on every noted item in the
      blocked, overdue and recently-completed sections, both on stdout and into
      the file `--write-to` names; `karr-foundation` accounted for the other
      ten, including the TIMEOUT notice App::karr::Foundation::Runner appends to
      `.karr.log`, which corrupted a file on disk and not merely a terminal. All
      eleven now spell the character `"\x{2014}"`, which also restores byte
      compatibility with kanban-md's own context block. t/124-source-ascii-only.t
      polices the class from here on, using PPI so that the em dashes in POD and
      comments — harmless, and plentiful — raise nothing.
    - Closed the last of the role import leaks: App::karr::Role::ClaimTimeout
      and App::karr::Role::TaskMutation no longer compose Time::Piece's
      `localtime` and `gmtime` into the commands that consume them — `move`,

Changes  view on Meta::CPAN

      `karr move --next` died on it. Those values are now checked against the
      board config before anything is written and rejected with exit 2, the
      usage-error code from ADR 0002. Status names are checked in the one
      shared status-change path, so `move` and `edit --status` cannot drift
      apart. A due date must be a real calendar date in `YYYY-MM-DD`, so
      `2026-02-30` is refused as well. A batch `edit` or `move` writes nothing
      at all rather than updating half the ids, and a rejected `create` no
      longer consumes a task id. Validation is on the write path only: a board
      that already carries a bad value stays readable so `karr move` can put it
      back.
    - Fixed `blocked` being incompatible with kanban-md. karr stored the
      blocking reason as free text in `blocked`; kanban-md has a boolean
      `blocked` plus a `block_reason` string, and its parser refuses a string
      there outright — a karr-blocked task was skipped as malformed and
      vanished from its board. karr now writes the kanban-md shape, and
      `--json` reports `blocked` as a JSON boolean instead of sometimes a
      string and sometimes `true`. `karr edit --block "reason"` and `karr
      handoff --block` are unchanged and still set both fields. Existing boards
      need no migration: a legacy free-text `blocked` is recognised on read and
      converted on the next write of that task.
    - Fixed unknown frontmatter fields being deleted on the first write. Any
      key karr did not model — a newer kanban-md field, a note added by hand in
      an editor — was dropped when the task was next saved. Unknown keys are
      now carried through untouched. They are not order-preserved: karr's YAML
      output is key-sorted, so a passthrough field lands in alphabetical
      position.
    - Fixed the lifecycle timestamps. `completed` was never cleared when a task
      was reopened, so every reopened task still looked finished; `started` was
      only set for the literal status `in-progress` and `completed` only for

Changes  view on Meta::CPAN

      it at the same time were given the same ID and the second task ref
      overwrote the first — 40 successful creates produced 32 tasks, with no
      warning on either side. Allocation is now a compare-and-swap against the
      counter ref, retried on contention, so concurrent agents always get
      distinct IDs.
    - Fixed task locking granting the same lock to every agent at once. Lock
      acquisition checked the lock ref and then wrote it, so all 16 contenders
      in a race passed the check and all 16 were told they had acquired it,
      while the ref could only hold one. Acquisition is now an atomic
      create-if-absent: exactly one agent wins and the rest get the usual
      "locked by ..." answer. On its own that does not make `karr pick` safe —
      see the entry below, which is what actually fixes concurrent picking.
    - Fixed `karr pick` handing the same task to several agents. Pick ranked
      candidates from a snapshot of the board read before any lock existed and
      never looked at the card again, so it claimed tasks that had been taken
      in the meantime: 12 parallel picks on a fresh 12-task board told nine
      agents they owned task 1, while the card named only the last of them. The
      lock was not the hole — its holder identity is the clone's `user.email`,
      which every agent on one machine shares, so all 12 acquired it quite
      legitimately. Each candidate is now re-read from its ref under its lock,
      re-tested with the same predicate, and written back under a

Changes  view on Meta::CPAN

0.302     2026-06-21 23:04:42Z

    - `karr board` now renders a compact, Markdown-flavoured plaintext board
      (board name as `#`, each status as `## Section`, one
      `- id | title | meta...` line per task) instead of the coloured column
      dashboard. The output stays clean when piped or redirected — colour is
      added only when stdout is a terminal and `NO_COLOR` is unset. Default
      (`medium`) priority is suppressed, and a new `--tags` flag prints each
      task's tags on an extra indented line.
    - Fix releasing a claim or unblocking a task leaving a null `claimed_by`,
      `claimed_at`, or `blocked` field behind. Clearing now uses real Moo
      clearers so the predicate drops and the field is omitted from the task
      file, instead of being written as an explicit null that reloaded as
      "still set" — which made `handoff` reject released tasks and `pick`
      treat them as claimed. Explicit nulls in already-written or external
      task files are normalized to "unset" on load.

0.301     2026-06-04 22:35:33Z

    - karr-foundation: stream agent output to the terminal when interactive
      (TTY detected) or --verbose is set. The parent process now reads the
      child's output through a native pipe and fans it to the log, the
      terminal, and an in-memory buffer — no external `tee` and no re-reading
      the log by byte offset. The per-run timeout is `select`-based (robust
      against Perl's deferred signals) and only fires when max_runtime > 0
      (max_runtime: 0 disables it entirely). Output is always appended to
      .karr.log regardless of TTY.
    - karr-foundation is now a multi-board coordinator, not just an agent
      runner. Agent execution is opt-in: with no agent configured on any
      board, the default action is a read-only overview of every board
      (status counts, in-progress/blocked, lock/cooldown state). `--status`
      forces that overview regardless of configuration.
    - 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`);

Changes  view on Meta::CPAN

    - Git.pm: read git config (user.name/email) and validate helper ref
      names through Git::Native (Config + reference_name_is_valid) instead
      of poking Git::Libgit2::FFI directly. New Git.pm `ref_oids` helper.
    - karr-foundation: detect board changes via Git::Native instead of
      shelling out to `git for-each-ref` — no git binary needed for that
      path anymore. Sync (`--pull`) and open-task detection now run
      in-process via App::karr::Git/BoardStore instead of forking the
      `karr` CLI.
    - karr-foundation: drain each board instead of a single run — invoke
      the agent command repeatedly until no actionable task (non-terminal
      and unblocked) remains. A task the agent claims but never moves is
      auto-blocked after `max_attempts` stalls (default 2) so the drain
      always terminates; the agent's own `--block` reason still wins.
      Observable common errors (non-zero/timeout exit, or a log match
      against rate-limit/auth/network/5xx patterns, extensible via
      `error_patterns`) never penalize a task and instead trigger an
      exponential per-repo cooldown (1, 2, 4, … minutes, capped). New
      `.karr` keys: `drain`, `max_attempts`, `max_iterations`,
      `cooldown_base`, `cooldown_max`, `error_patterns`.
    - cpanfile: require Git::Native 0.003 and Git::Libgit2 0.004.
    - Fix `karr context` / `karr context --json` crashing with
      "Can't locate object method 'strftime' via package 'Sun May ...'":

bin/karr  view on Meta::CPAN

=item * L<App::karr::Cmd::List>

Lists tasks with filters for status, priority, tags, claims, and text search.

=item * L<App::karr::Cmd::Show>

Shows the full task document for one task id.

=item * L<App::karr::Cmd::Edit>

Updates task metadata, body text, claims, and blocked state.

=item * L<App::karr::Cmd::Move>

Moves tasks between statuses, including relative moves with C<--next> and
C<--prev>.

=item * L<App::karr::Cmd::Delete>

Deletes task refs permanently.

docs/adr/0003-board-level-disable-is-board-state-and-absolute.md  view on Meta::CPAN

- **It is absolute.** Foundation checks the flag before it resolves the agent
  command and before it decides whether to drain, so a disabled board is
  skipped whole — no drain, no auto-block, no agent run. It therefore wins over
  `karr-foundation --command`, the config's `default_command`, the `.karr`
  `command` and `claude: true`. `--force` does **not** override it.

The gap this closes: with a global `default_command` in
`~/.config/karr-foundation/config.yml`, *every* discovered board became an agent
board and a repository had no way to opt out. The motivating case is a parked
backlog — an abandoned project kept for reference, with backlog and review tasks
that look actionable but must never be drained or auto-blocked.

## Considered Options

- **A key in the per-repo `.karr` file** — rejected: `.karr` is local machine
  state (it sits next to `.karr.state` / `.karr.lock` / `.karr.log`, all
  gitignored). "This board is parked" is a fact about the board, and every host
  that discovers the repo must learn it without manual setup.
- **`--force` overrides the flag** — rejected: it re-creates the problem one
  level up. An automation host that runs with `--force` (a common cron shape,
  to run regardless of board change) would drain every disabled board anyway.

docs/superpowers/plans/2026-03-15-karr-git-sync.md  view on Meta::CPAN

    return "refs/karr/tasks/" . $self->task_id . "/lock";
}

sub can_acquire {
    my ($self, $agent) = @_;
    my $repo = Git::Raw::Repository->open($self->board_dir->stringify);
    my $ref = $self->ref_path;
    my $current = eval { $repo->reference($ref) };
    return 1 unless $current;
    my $content = $current->target->content;
    chomp(my $locked_by = $content);
    return $locked_by eq '' || $locked_by eq $agent;
}

sub acquire {
    my ($self, $agent) = @_;
    return 0 unless $self->can_acquire($agent);
    my $repo = Git::Raw::Repository->open($self->board_dir->stringify);
    my $ref = $self->ref_path;
    my $blob = $repo->blob($agent);
    my $commit = eval { $repo->reference($ref) };
    # Create or update ref

docs/superpowers/plans/2026-03-15-karr-git-sync.md  view on Meta::CPAN


- [ ] **Step 3: Fix Lock.pm for Git::Raw API**

```perl
# Simplified: use git command for now
sub can_acquire {
    my ($self, $agent) = @_;
    my $ref = $self->ref_path;
    my $content = `cd @{[$self->board_dir]} && git cat-file -p $ref 2>/dev/null`;
    return 1 unless $content;
    my ($locked_by) = $content =~ /^(\S+)/m;
    return !$locked_by || $locked_by eq $agent;
}

sub acquire {
    my ($self, $agent) = @_;
    return 0 unless $self->can_acquire($agent);
    my $ref = $self->ref_path;
    system("cd @{[$self->board_dir]} && git update-ref $ref $agent");
    return $? == 0;
}

docs/superpowers/plans/2026-03-15-karr-git-sync.md  view on Meta::CPAN

sub auto_sync {
    my ( $self, $task_id, $agent ) = @_;
    return if $self->no_sync;

    my $lock = App::karr::Lock->new(
        board_dir => $self->board_dir,
        task_id => $task_id,
    );

    unless ( $lock->acquire($agent) ) {
        die "Task $task_id is locked by another agent";
    }

    $self->sync->pull;
    # Do the actual operation
    $self->sync->push;
    $lock->release($agent);
}

1;
```

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

    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**

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

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

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

    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;

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

                $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,

docs/superpowers/plans/2026-03-22-helper-refs-and-docs.md  view on Meta::CPAN


### Task 1: Write the failing helper-ref tests

**Files:**
- Modify: `t/00-load.t`
- Create: `t/21-helper-refs.t`

- [ ] Add `App::karr::Cmd::SetRefs` and `App::karr::Cmd::GetRefs` to `t/00-load.t`.
- [ ] Write a failing integration test for:
  - bare ref normalization to `refs/...`,
  - blocked namespaces,
  - single-ref push/fetch between two repositories.
- [ ] Run `prove -l t/00-load.t t/21-helper-refs.t` and confirm red.

### Task 2: Implement Git helper methods

**Files:**
- Modify: `lib/App/karr/Git.pm`
- Test: `t/21-helper-refs.t`

- [ ] Add `normalize_ref_name`.

docs/superpowers/specs/2026-03-15-karr-git-sync-design.md  view on Meta::CPAN

```

**Task Metadata (YAML):**
```yaml
---
id: 1
title: "Fix login bug"
status: in-progress
claimed_by: agent-fox
priority: high
blocked_by: "waiting on API"
external:
  - type: github
    repo: owner/repo
    issue: 42
messages:
  - author: agent-fox
    text: "Ich arbeite dran"
    timestamp: 2026-03-15T10:00:00Z
  - author: agent-owl
    text: "Kann ich übernehmen?"

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


Three agents on the same repo, each in a separate terminal/worktree.

```bash
# Agent A picks first available task:
karr pick --claim agent-a --move in-progress --json
# → picks task 3 (highest priority unclaimed)

# 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


- 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

Requested refs must pass both structural validation and namespace validation.

### Blocked namespaces

The following namespaces are denied:

- `refs/heads/`
- `refs/tags/`
- `refs/remotes/`
- `refs/bisect/`
- `refs/replace/`
- `refs/stash`
- `refs/karr/`

docs/superpowers/specs/2026-03-22-helper-refs-design.md  view on Meta::CPAN

- empty path segments,
- leading or duplicate slashes,
- `..`,
- `@{`,
- control characters,
- spaces,
- Git-special characters such as `~ ^ : ? * [ \`,
- `.lock` suffixes,
- trailing dots.

Errors must distinguish between invalid ref syntax and blocked namespaces.

## Architecture

`App::karr::Git` remains the low-level Git boundary. The new feature extends it
with helper methods for:

- ref normalization,
- helper-ref validation,
- single-ref fetch,
- single-ref push.

docs/superpowers/specs/2026-03-22-helper-refs-design.md  view on Meta::CPAN

- `App::karr` gets a short section describing helper refs and the AI/agent use
  case.
- The new command modules receive full POD.
- Existing command and main-module POD should mention Docker as a peer runtime
  option after the Perl-first examples.
- `README.md` already covers Docker prominently, so the Perl POD only needs a
  concise nod to it rather than duplicating the full README.

## Verification

- Add focused tests for ref normalization, validation, and blocked namespaces.
- Add an integration test that pushes and fetches a helper ref across two Git
  repositories.
- Extend the load test to include the new command modules.
- Run `prove -l t`.
- Run `podchecker` across the modified modules.

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

      if ($t->priority && $t->priority ne 'medium') {
        push @meta, $c->('priority:' . $t->priority, $PRIORITY_COLOR{$t->priority} // 'white');
      }
      # A claim is only worth showing while the work is still live, and which
      # columns count as finished is the board's decision -- a board imported
      # from kanban-md can end in `shipped`, and every finished card there
      # still carried its claimant into the board (ticket #98, following #67).
      if ($t->has_claimed_by && !$self->store->is_terminal_status($t->status)) {
        push @meta, $c->('@' . $t->claimed_by, 'cyan');
      }
      if ($t->has_blocked) {
        my $reason = $t->has_block_reason ? $t->block_reason : undef;
        $reason = substr($reason, 0, 40) . '...' if defined $reason && length $reason > 43;
        push @meta, $c->(
          defined $reason && length $reason ? "blocked:$reason" : 'blocked', 'bold red');
      }
      if ($t->has_due) {
        push @meta, $c->('due:' . $t->due, 'yellow');
      }

      my $line = join ' ', $c->('-', 'bright_black'), $t->id, $sep, $t->title;
      $line .= " $sep " . join(" $sep ", @meta) if @meta;
      print $line, "\n";

      if ($self->tags && @{$t->tags}) {
        print '  ', $c->(join(' ', map { "#$_" } @{$t->tags}), 'bright_black'), "\n";
      }
    }
  }

  # Summary footer
  my $blocked = grep { $_->has_blocked } @tasks;
  # Same test as the per-card `@claimant` token above, so the footer can never
  # count a claim the board itself does not show.
  my $claimed = grep { $_->has_claimed_by && !$self->store->is_terminal_status($_->status) } @tasks;
  my $done_hidden = $self->done ? 0 : scalar @{ $by_status{done} // [] };
  my $total_label = scalar(@tasks) . ' tasks';
  $total_label .= " ($done_hidden done hidden)" if $done_hidden;
  my @summary = ( $total_label );
  push @summary, "$claimed claimed" if $claimed;
  push @summary, "$blocked blocked" if $blocked;
  print "\n", $c->(join('  ', @summary), 'bold'), "\n";
}

1;

__END__

=pod

=encoding UTF-8

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


=head1 OUTPUT MODES

=over 4

=item * Default output

Lists every status as a C<## Status> section (in board order, empty sections
included; an empty C<archived> is hidden, and C<done> is hidden unless C<--done>
is given). Each task renders as C<- id | title> followed by C<priority>
(non-default only), C<@claimant>, C<blocked:reason>, and C<due:date> tokens where
applicable. A footer line totals tasks, claims, and blocks, and — when the
C<done> section is hidden and non-empty — appends a C<(N done hidden)> hint so
the count is not silently lost.

=item * C<--tags>

Adds an extra indented line of C<#tag> tokens beneath each task that has tags.

=item * C<--done>

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


option write_to => (
  is => 'ro',
  format => 's',
  doc => 'Write context to file (create or update)',
);

option sections => (
  is => 'ro',
  format => 's',
  doc => 'Comma-separated section filter (in-progress,blocked,overdue,recently-completed,activity)',
);

option days => (
  is => 'ro',
  format => 'i',
  default => sub { 7 },
  doc => 'Lookback days for recently-completed (default: 7)',
);

option activity_limit => (

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

  # is worse than usual: the falsy guard in _recent_activity reads 0 as "no
  # bound at all", so the one option whose whole job is to keep the briefing
  # short would silently pour the entire log into it, and a negative produced
  # an empty section and exit 0, indistinguishable from "nobody else acted".
  # Same rule and same reason as `show --last` (ticket #76, ADR 0002).
  $self->usage_error(
    sprintf '--activity-limit must be 1 or greater (got %d)', $self->activity_limit )
    if $self->activity_limit < 1;

  # A briefing built from a board that was never read says "0 tasks, nothing
  # blocked, nothing overdue" -- the most confident possible way to be wrong,
  # and --write-to would then paste it into AGENTS.md (#135).
  $self->require_local_board;

  my $ec = $self->store->effective_config;
  my @tasks = $self->load_tasks;
  my @statuses = $self->store->all_status_names;

  # Determine terminal and first statuses
  my $first_status = $statuses[0];

  # Exclude archived from all operations
  my @active_tasks = grep { !$self->store->is_terminal_status($_->status) } @tasks;

  # Build summary
  my $board_name = $ec->{board}{name} // 'Kanban Board';
  my $total = scalar @active_tasks;
  my $active = grep { $_->status ne $first_status && !$self->store->is_terminal_status($_->status) } @active_tasks;
  my $blocked = grep { $_->has_blocked } @active_tasks;
  my $overdue = $self->_count_overdue(\@active_tasks);

  # Build sections
  my %wanted_sections;
  if ($self->sections) {
    %wanted_sections = map { $_ => 1 } split /,/, $self->sections;
  }

  my @section_data;
  my @all_sections = qw(in-progress blocked overdue recently-completed activity);

  for my $sec (@all_sections) {
    next if $self->sections && !$wanted_sections{$sec};
    my @items;

    if ($sec eq 'in-progress') {
      @items = map { $self->_task_item($_) }
        sort { $self->_pri_order($a) <=> $self->_pri_order($b) }
        grep { $_->status ne $first_status && !$self->store->is_terminal_status($_->status) && !$_->has_blocked }
        @active_tasks;
    } elsif ($sec eq 'blocked') {
      @items = map { $self->_task_item($_, 'blocked: ' . ($_->has_block_reason ? $_->block_reason : '')) }
        grep { $_->has_blocked }
        @active_tasks;
    } elsif ($sec eq 'overdue') {
      my $now = gmtime->strftime('%Y-%m-%d');
      @items = map { $self->_task_item($_, 'due ' . $_->due) }
        grep { $self->_is_overdue($_, $now) }
        @active_tasks;
    } elsif ($sec eq 'recently-completed') {
      # Over every task, not @active_tasks: that list is by definition the
      # non-terminal ones, so intersecting it with the terminal statuses was
      # empty by construction and this section had never once had an entry on

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


    push @section_data, { name => $sec, items => \@items } if @items;
  }

  if ($self->json) {
    my $out = {
      board_name => $board_name,
      summary => {
        total_tasks => $total,
        active => $active,
        blocked => $blocked,
        overdue => $overdue,
      },
      sections => \@section_data,
    };
    $self->print_json($out);
    return;
  }

  # Render markdown
  my $md = $self->_render_markdown($board_name, $total, $active, $blocked, $overdue, \@section_data);

  if ($self->write_to) {
    $self->_write_to_file($md);
  } else {
    print $md;
  }
}

sub _render_markdown {
  my ($self, $board_name, $total, $active, $blocked, $overdue, $sections) = @_;
  # The "kanban-md" spelling in these BEGIN/END markers (and the matching
  # regex in _write_to_file below) is an intentional interop contract: karr
  # and kanban-md maintain the same context block inside a shared host file
  # (e.g. AGENTS.md) by matching identical sentinels, so switching tools
  # updates the same block and leaves no orphaned markers. Do NOT rename to
  # "karr".
  my $md = "<!-- BEGIN kanban-md context -->\n";
  $md .= "## Board: $board_name\n\n";
  $md .= "**$total tasks** | $active active | $blocked blocked | $overdue overdue\n\n";

  my %section_title = (
    'in-progress'        => 'In Progress',
    'blocked'            => 'Blocked',
    'overdue'            => 'Overdue',
    'recently-completed' => 'Recently Completed',
    'activity'           => 'Recent Activity',
  );

  for my $sec (@$sections) {
    $md .= "### " . ($section_title{$sec->{name}} // $sec->{name}) . "\n\n";
    if ($sec->{name} eq 'activity') {
      # An activity item is a log event, not a task -- it has no priority or
      # assignee to report, so it gets its own line shape instead of being

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


App::karr::Cmd::Context - Generate board context summary for embedding

=head1 VERSION

version 0.500

=head1 SYNOPSIS

    karr context
    karr context --sections blocked,overdue
    karr context --write-to AGENTS.md --days 14
    karr context --activity-limit 10
    karr context --json

=head1 DESCRIPTION

Builds a concise board summary suitable for embedding into agent context files
such as F<AGENTS.md>. The command can print Markdown directly, emit structured
JSON, or update an existing file between sentinel comments.

=head1 SECTIONS

The generated context can include C<in-progress>, C<blocked>, C<overdue>,
C<recently-completed>, and C<activity>. Use C<--sections> with a
comma-separated list to limit the output to a subset.

C<activity> is the board's activity log (see L<App::karr::Cmd::Log>), filtered
to entries written by identities other than the one invoking C<context> and
bounded by C<--activity-limit> (default 5). An agent reading its own briefing
already knows what it just did -- C<karr show --me> is the tool for that --
so what belongs in a briefing is what everyone *else* has been doing.

=head1 FILE UPDATE MODE

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

);

option release => (
  is => 'ro',
  doc => 'Release claim',
);

option block => (
  is => 'ro',
  format => 's',
  doc => 'Mark as blocked with reason',
);

option unblock => (
  is => 'ro',
  doc => 'Clear blocked state',
);

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

  $self->check_positional_args($args_ref, 1);

  $self->sync_before;
  $self->require_board;

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


    karr edit 5 --title "Updated title"
    karr edit 5 --add-tag urgent --remove-tag stale
    karr edit 5 --add-depends-on 2,3 --remove-depends-on 4
    karr edit 5 -a "Waiting for review"
    karr edit 5 --claim agent-fox --block "waiting on API"

=head1 DESCRIPTION

Updates one or more existing tasks in place. Use it to adjust metadata, append
notes, manage tags, claim or release ownership, and mark tasks as blocked or
unblocked without changing the task id.

=head1 COMMON OPERATIONS

=over 4

=item * Metadata updates

C<--title>, C<--status>, C<--priority>, C<--assignee>, and C<--due> replace
existing values. C<--status> is the same status change L<App::karr::Cmd::Move>
performs and obeys the same rules, C<require_claim> included.

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

  # The handoff target is a working column, so apply_status_change above will
  # have recorded any unsatisfied dependencies; this emits them (ticket #123).
  my %dependency = $self->dependency_report( $task->id );

  if ($self->json) {
    $self->print_json({ %{ $task->to_json_hash }, %dependency });
    return;
  }

  my $msg = sprintf "Handed off task %d -> %s", $task->id, $target;
  $msg .= sprintf " (blocked: %s)", $self->block if $self->block;
  $msg .= " (claim released)" if $self->release;
  print "$msg\n";
}

1;

__END__

=pod

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


  printf "%-5s %10s %s\n", 'ID', 'STATUS', 'TITLE';
  printf "%s\n", '-' x 72;
  for my $t (@tasks) {
    my @meta;
    push @meta, $t->priority if defined $t->priority && length $t->priority;
    # An `assignee: ""` from kanban-md satisfies the predicate but names
    # nobody, and printing it gave every imported card a bare "@" in its meta
    # list. Empty means absent here as it does in pick (ticket #59).
    push @meta, '@' . $t->assignee if $t->has_assignee && length $t->assignee;
    push @meta, 'blocked' if $t->has_blocked;
    my $title = $t->title;
    $title .= ' [' . join(', ', @meta) . ']' if @meta;

    printf "#%-4u %10s %s\n",
      $t->id,
      $t->status,
      $title;
  }
  printf "\n%d task(s)\n", scalar @tasks;
}

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

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

  $self->sync_before;
  $self->require_board;

  my $ec = $self->store->effective_config;
  my $timeout = $self->_parse_timeout($ec->{claim_timeout} // '1h');

  # Before any lock is taken: --move is a plain option value, and a bad one used
  # to be discovered only after a task had already been locked and claimed, so
  # the pick parked it in a status that is not a column (ticket #54). Pick does
  # not go through apply_status_change -- it has its own compare-and-swap loop --
  # so the check is here.
  App::karr::Config->from_merged($ec)->validate_status($self->move)
    if defined $self->move;

  # A ranking, not a decision. Every one of these is re-read and re-tested under
  # its own lock before anything is written (see EXCLUSIVITY above).
  my @tasks = grep { $self->_is_pickable($_, $timeout) } $self->load_tasks;

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

    $picked = eval { $self->_claim_under_lock($candidate->id, $timeout) };
    my $err = $@;
    $lock->release($candidate->id, $email);
    die $err if $err;

    last if $picked;
  }

  unless ($picked) {
    return $self->_nothing_picked(
      "No available tasks to pick (every candidate was locked or taken).");
  }

  # Both writes have to happen before the push, or they never leave this clone:
  # sync_after is the last thing that talks to the remote and it disarms the
  # SyncGuard behind it. The lock release above is the same story -- publishing
  # a lock and then deleting it locally left the ref on the remote forever (#45).
  $self->append_log($self->git,
    agent   => $self->claim,
    action  => 'pick',
    task_id => $picked->id,

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

  # one -- can carry the empty string, and Moo's predicate calls that "set". So
  # every imported kanban-md card looked as though somebody held it, and pick
  # reported an empty board while `karr list` showed the work sitting there
  # (ticket #59).
  # This is the same emptiness test App::karr::Role::ClaimTimeout/check_claim
  # already applies; the two have to agree or a task pick refuses is a task
  # move happily accepts.
  return 0 if $task->has_claimed_by
    && length $task->claimed_by
    && !$self->_claim_expired($task, $timeout);
  return 0 if $task->has_blocked;

  if ($self->tags) {
    my %wanted = map { $_ => 1 } split /,/, $self->tags;
    return 0 unless grep { $wanted{$_} } @{$task->tags};
  }

  return 1;
}

# Claim one candidate, or return false if it is no longer ours to claim.

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


=head1 SYNOPSIS

    karr pick --claim agent-fox
    karr pick --claim agent-fox --status todo --move in-progress
    karr pick --claim agent-fox --tags backend,urgent --json

=head1 DESCRIPTION

Selects the next available task for an agent, taking class of service,
priority, blocked state, and claim expiry into account. When the board lives in
a Git repository, the command also uses lock refs so concurrent agents do not
pile onto the same candidate.

=head1 SELECTION RULES

=over 4

=item * Eligible statuses

If C<--status> is omitted, tasks in the board's terminal statuses are excluded

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

  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 "Depends:  %s\n",
    join( ', ', map { $self->_dependency_label($_) } @{$task->depends_on} )
    if @{$task->depends_on};
  printf "Claimed:  %s\n", $task->claimed_by if $task->has_claimed_by;
  printf "Blocked:  %s\n", $task->has_block_reason ? $task->block_reason : 'yes'
    if $task->has_blocked;
  printf "Created:  %s\n", $task->created;
  printf "Updated:  %s\n", $task->updated;
  if (defined $task->body && length $task->body) {
    print "\n" . $task->body . "\n";
  }
}

# A bare id list answers the wrong question: `depends_on: [5]` tells the reader
# nothing about whether 5 is finished, which is the only thing they wanted to
# know. Half of what made ticket #123 a trap was that `show` did not print the

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

  my @results;
  for my $id (@ids) {
    my ($ok, $owner) = $lock->break_lock($id);
    push @results, {
      id      => 0 + $id,
      broken  => $ok ? \1 : \0,
      ( $ok ? ( owner => $owner ) : () ),
    };
    next if $self->json;
    if ($ok) { printf "Broke lock on task %d (was held by %s)\n", $id, $owner }
    else     { printf "Task %d is not locked\n", $id }
  }

  $self->sync_after;

  $self->print_json_results(@results);
}

sub _report {
  my ($self, @held) = @_;

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

  # claude: true synthesis). Agent execution is opt-in: a board with no agent
  # is shown in the overview, not run.
  my $cmd = $self->_agent_command( $repo, $karr );
  unless ( defined $cmd ) {
    $self->_say_verbose("skip $repo \x{2014} no agent configured (see --status)");
    return;
  }

  # Check lock — skip if another instance is running
  if ( $self->_lock_held( $repo ) ) {
    $self->_say_verbose("skip $repo \x{2014} locked by running agent");
    return;
  }

  # Respect exponential cooldown left by a previous common-error run
  if ( $self->_cooldown_active( $repo ) ) {
    my $until = $self->_state_get( $repo, 'cooldown_until' ) // 0;
    $self->_say_verbose( "skip $repo \x{2014} in cooldown for " . ( $until - time ) . "s" );
    return;
  }

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

  $self->_say_verbose(
    "skip $repo \x{2014} board disabled" . ( defined $reason ? ": $reason" : '' ) );
  return 1;
}

# ---------------------------------------------------------------------------
# Task state / actionability
# ---------------------------------------------------------------------------

# A task is actionable when an agent could still pick it: not terminal
# (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 ) {
    return 1 if $self->_is_actionable( $states{$id} );

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

      last;
    }

    $outcome = 'progress' if $progressed;

    my %after = $self->_task_states( $repo );
    $self->_note_engagement( $repo, $eng );
    my @stuck = $self->_stuck_tasks( \%before, \%after, $eng );

    # Reset the attempt counter for any task that is no longer stuck
    # (advanced, blocked, or gone), then bump/auto-block the stuck ones.
    my %is_stuck = map { $_ => 1 } @stuck;
    my $attempts = $self->_state_get( $repo, 'attempts' ) // {};
    $self->_reset_attempts( $repo, $_ ) for grep { !$is_stuck{$_} } keys %$attempts;

    for my $id ( @stuck ) {
      my $n = $self->_bump_attempts( $repo, $id );
      next if $n < $max_attempts;
      $self->_autoblock_task( $repo, $id,
        "auto-block: no progress after $n attempts (foundation)",
        $eng->{claims} );

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

there is no drain, no auto-block and no agent run. It therefore wins over
C<--command>, the config's C<default_command>, the F<.karr> C<command> and
C<< claude: true >>, and C<--force> does B<not> override it. Use it for a
repository whose backlog is parked (an abandoned project kept for reference)
that a globally configured C<default_command> would otherwise drain. C<--status>
shows such a board with a C<disabled> flag and its reason.

B<Coordinator and overview.> Agent execution is opt-in — a board runs an agent
only via C<command> or C<< claude: true >>. When B<no> board has an agent
configured, the default action is a read-only B<overview> of every board
(status counts, in-progress/blocked tasks, lock and cooldown state); a human
can use foundation purely to coordinate their own work. C<--status> forces the
overview regardless of configuration.

B<Live output.> When run interactively (TTY) or with C<--verbose>, the agent's
output is streamed to the terminal in real time as foundation reads it; it is
always appended to F<.karr.log> regardless of TTY. To shape what is shown, the
command may emit stream-json and filter it, e.g.:

  command: >-
    claude -p "$PROMPT"

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


B<Drain semantics.> Each iteration runs C<command> once, then classifies the
result from what foundation can observe — exit code, board ref movement, and
the run's captured output:

=over 4

=item * B<progress> — the board changed; keep draining.

=item * B<stall> — a task B<this run's agent engaged> did not move. That task's
attempt counter is bumped; at C<max_attempts> it is auto-blocked
(C<blocked: auto-block: no progress after N attempts (foundation)>) so it drops
out of the actionable set and the drain can finish. The agent may always set a
better reason itself with C<karr edit --block>; the auto-block is a fallback.

B<Engaged> means foundation can prove the agent worked on that card during
B<this> drain: the agent runs with C<KARR_ROLE=agent>, so every C<karr> write
it makes is recorded in the board's own activity log under the C<agent>
identity, and only the tasks named there — held by nobody, or by a claim name
the agent itself wrote under — can be penalized. A card somebody else holds is
never touched, and neither is one the agent merely left claimed in an earlier
run: a stale claim is what C<claim_timeout> and C<karr unlock> are for. Where

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

# Overview (read-only dashboard)
# ---------------------------------------------------------------------------

sub _print_overview {
  my ( $self, $repos ) = @_;
  for my $repo (@$repos) {
    my $karr   = $self->foundation->_load_karr($repo);
    my %states = $self->foundation->_task_states($repo);

    my %count;
    my ( @in_progress, @blocked );
    for my $id ( sort { $a <=> $b } keys %states ) {
      my $st = $states{$id};
      $count{ $st->{status} // 'unknown' }++;
      push @in_progress, $id if ( $st->{status} // '' ) eq 'in-progress';
      push @blocked,     $id if $st->{blocked};
    }

    # The board's own opt-out (refs/karr/config: foundation.enabled) is the
    # dominant fact about a board, so it leads the flag list — and suppresses
    # the 'agent' flag, because that agent will never run here.
    my $disabled = $self->foundation->_board_disabled($repo);

    my @flags;
    push @flags, 'disabled' if $disabled;
    push @flags, 'agent-running' if $self->foundation->_lock_held($repo);

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

    printf "%s\n", $repo->basename;
    printf "  %d tasks", $total;
    print '  [' . join( ', ', @flags ) . ']' if @flags;
    print "\n";
    if (%count) {
      printf "  %s\n", join( '  ', map { "$_:$count{$_}" } sort keys %count );
    }
    printf "  disabled:    %s\n", $disabled->{reason} // 'no reason given'
      if $disabled;
    printf "  in-progress: %s\n", join( ', ', map { "#$_" } @in_progress ) if @in_progress;
    printf "  blocked:     %s\n", join( ', ', map { "#$_" } @blocked )     if @blocked;
    print "\n";
  }
  return;
}

1;

__END__

=pod

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


=head1 VERSION

version 0.500

=head1 DESCRIPTION

L<App::karr::Foundation::Overview> renders the read-only cross-board dashboard
shown by C<karr-foundation --status> and whenever no board has an agent
configured: per repo it prints the task-status counts, the in-progress and
blocked task ids, and disabled / lock / cooldown / agent flags. A board that
opted out of automated agent runs (C<karr disable>) is shown with a C<disabled>
flag and a C<disabled:> line carrying its reason; its C<agent> flag is
suppressed, because no agent runs there. A board in cooldown carries the
remaining wait and the error that caused it (C<cooldown 240s (rate limit)>),
since a parked board is a board doing nothing. A weak back-reference to the
owning foundation supplies the board data and state helpers.

=head1 SUPPORT

=head2 Issues

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

    defined $ref or die "Ref name is required\n";
    $ref =~ s{^/+}{};
    return $ref =~ m{^refs/} ? $ref : "refs/$ref";
}


sub validate_helper_ref {
    my ( $self, $ref ) = @_;
    my $full_ref = $self->normalize_ref_name($ref);

    my @blocked = (
        'refs/heads/',
        'refs/tags/',
        'refs/remotes/',
        'refs/bisect/',
        'refs/replace/',
        'refs/karr/',
        # Pick locks (App::karr::Lock). They were moved out of refs/karr/ so
        # that no refspec could publish them (#93); `karr set-refs` names a ref
        # and pushes it, so leaving it able to reach them would put the same
        # hole back one command over.
        'refs/karr-local/',
    );

    for my $prefix (@blocked) {
        die "Ref '$full_ref' is in a protected namespace\n"
            if index( $full_ref, $prefix ) == 0;
    }
    die "Ref '$full_ref' is in a protected namespace\n"
        if $full_ref eq 'refs/stash' || index( $full_ref, 'refs/stash/' ) == 0;

    # Native validity check via Git::Native.
    die "Ref '$full_ref' is not a valid git ref name\n"
        unless Git::Native->reference_name_is_valid($full_ref);

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

# remove. It used to swallow the exception and answer 1 regardless (#51), which
# made a delete that did nothing look like a delete that worked, and bumped
# $WRITES either way. That counter is what SyncGuard reads to decide whether
# local refs still need pushing, so it may only ever count writes that landed.
#
# A delete that was attempted and refused dies, as every other ref mutation in
# this class does. It used to answer 0 for that too -- the same 0 as "was never
# there" -- and break_lock read that 0 as "gone", so `karr unlock` reported
# "Broke lock on task N" over a lock that was still standing (#119). unlock is
# the escape hatch for a holder that never came back; a false success there
# leaves the card locked for everyone with nobody left to look.
#
# 0 therefore means "the ref is not on this board", never "we could not tell".
# The one seam is an unopenable repository, which is not a refusal to delete
# but the whole class degrading to no-ops -- during global destruction _repo is
# false by design and nothing native may run or throw (#63).
#
# Contention retries on the same terms as write_ref: losing the race for
# refs/<name>.lock is not a failed delete, it is one that has not been
# attempted yet.
sub delete_ref {

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

# answers that with exit 0 and no output, i.e. "not tracked" (#113). Pinning
# the cwd here puts both routes on one origin instead of correcting the
# pathspec at each call site. It costs the transport verbs nothing: git
# discovers the same repository from either directory. Only a repository
# libgit2 cannot open has no root to run from, and there ->dir is all that is
# left -- the same degradation every other native operation makes.
#
# Both pipes are drained through one IO::Select loop. Reading stdout to EOF
# first, as this used to, deadlocks the moment the child fills the 64 KiB
# stderr pipe buffer: the child blocks on write and so never exits or closes
# stdout, while the parent is still blocked reading stdout. A diverged board
# reaches that at roughly 700 rejected refs, and it could strike inside
# bin/karr's END flush, i.e. after the command had already printed its result
# (#43). The loop is also bounded by a deadline, so a transport that stalls
# (an ssh ProxyCommand hanging on a jump host, a grandchild holding the pipes
# open past the child's exit) fails instead of hanging an unattended agent.
#
# `status` is the raw waitpid status, not `$? >> 8`, so callers can tell a
# clean exit from a death by signal (#42).
sub _run_git {
    my ( $self, @args ) = @_;

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


    return $git->retry_contended( "the lock on task $task_id", sub {
        my ( $oid, $current ) = $git->read_ref_with_oid($ref);

        my $broke = '';
        if ( defined $oid && length $current && $current ne $email ) {
            # Held by somebody else: a final answer, not contention. Say so
            # rather than dying, which is what the raw libgit2 lock error used
            # to do -- unless the lock has expired, in which case its holder is
            # gone and leaving it there would make the task unpickable forever.
            return ( 0, "locked by $current" ) unless $self->expired($oid);
            $broke = " (broke stale lock held by $current)";
        }

        # $oid undef => create-if-absent, the exclusive case. $oid set => this
        # is our own lock being refreshed, or a stale one being taken over;
        # either way the write is guarded against the ref not having moved since
        # the read above.
        return () unless $git->write_ref_cas( $ref, $email, $oid );
        return ( 1, "acquired$broke" );
    } );

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

        # App::karr::Git::delete_ref, the lock is gone whether this call
        # removed it or another unlock did in the same breath, and a delete
        # that was refused raises instead of answering. That is what makes
        # $broke honest -- until #119 it only meant "the ref was there when I
        # looked", and a refused delete was announced as a broken lock while
        # the holder kept the card. Anything that goes back to reading a soft
        # answer out of delete_ref has to earn this line again.
        $self->git->delete_ref($ref);
        $broke = 1;
    }
    return ( 0, "not locked" ) unless $broke;
    return ( 1, $owner );
}


# Giving a lock back is a guarded delete: the holder is re-read and the removal
# is guarded against that exact revision, so a lock that was broken and re-taken
# between the two is not dropped by whoever held it before (#94). An unguarded
# delete here could evict a live holder that has nothing to do with this call.
sub release {
    my ( $self, $task_id, $email ) = @_;

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

    my $ref = $self->ref_name($task_id);
    my $git = $self->git;

    return $git->retry_contended( "the lock on task $task_id", sub {
        my ( $oid, $current ) = $git->read_ref_with_oid($ref);

        # Nothing of ours to give back: already released, already broken, or
        # expired and taken over. Not an error -- release is the tail of a pick
        # that has otherwise finished.
        return ( 1, "released" ) unless defined $oid;
        return ( 0, "locked by $current" )
            if length $current && $current ne $email;

        return () unless $git->delete_ref_cas( $ref, $oid );
        return ( 1, "released" );
    } );
}


1;

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


=head2 Locks are local, and live outside the board

Lock refs live under C<refs/karr-local/>, which nothing pushes, fetches, prunes
or snapshots. A lock says "this process, in this clone, is mid-pick right now",
and that sentence has no meaning anywhere else: a clone that receives one cannot
tell whether the holder is still alive, and has no way to find out.

They used to live at C<refs/karr/tasks/N/lock>, inside the namespace C<karr>
pushes. Any sync that fired while a lock was held published it, other clones
pulled it, and it then blocked their picks until somebody ran C<karr unlock> --
a lock that outlived the process holding it and the machine it ran on (#93). It
also turned every board backup into a snapshot of somebody's momentary lock.
Moving the refs out is what makes that impossible, rather than making it depend
on the timing of when a lock happens to be released.

Locks left in the old place by a C<karr> older than this one -- or pulled from a
remote that still has them -- are not acted on: they cannot say anything about
this process, and a pick's exclusivity does not rest on them anyway. They are
not ignored either. C<locks> reports them, marked C<legacy>, and C<break_lock>
clears them, so C<karr unlock> is the way out of the mess the old layout left

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

C<$email>. Always returns a two-element list for its ordinary outcomes,
rather than throwing:

=over 4

=item * C<(1, "acquired")> -- taken, nobody held it.

=item * C<(1, "acquired (broke stale lock held by $prior)")> -- taken over
from a holder whose lock had passed its L</ttl>; see L</expired>.

=item * C<(0, "locked by $current")> -- held by somebody else and not
expired. This is a final answer, not contention: the caller should treat it
as "somebody else has this one" and try a different task, not retry.

=back

The lock is not what makes a pick exclusive by itself -- see
L</DESCRIPTION> -- so losing the race here means trying a different task,
not that a concurrent pick is unsafe.

Acquisition is a single compare-and-swap per attempt, retried automatically
against Git ref contention (L<App::karr::Git/retry_contended>). That retry
loop, not this method, is what throws: if the ref stays contended across
every retry -- many agents writing the board at once -- the C<die> from
L<App::karr::Git/retry_contended> propagates uncaught. That is a distinct
failure from "locked by somebody else" above and is not expected in
ordinary use.

=head2 expired

    my $stale = $lock->expired($oid);

Whether the lock commit C<$oid> points at is older than L</ttl>. Takes the
commit OID a lock ref currently resolves to, not a C<$task_id> -- taken from
L</locks>, or from the OID L</acquire> reads before deciding whether to
steal. Guarding a takeover against the exact OID whose age was judged is

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

here acts on what it finds; that is L</break_lock>.

=head2 break_lock

    my ( $ok, $owner ) = $lock->break_lock(12);

Clears the lock on C<$task_id> (defaulting to L</task_id>) unconditionally
-- regardless of who holds it or whether L</expired> says it is stale -- at
both L</ref_name> and L</legacy_ref_name>. Returns C<(1, $owner)> naming
whoever held it (the current-layout holder if both were set), or
C<(0, "not locked")> if neither ref existed. This is the escape hatch
L</release> deliberately is not: C<karr unlock> is built on this, not on
L</release>, because the whole problem it solves is a holder that is never
coming back to release anything.

C<(1, $owner)> means the lock is really gone. When a lock ref exists and
refuses to be removed, this C<die>s with the C<karr: could not delete ...>
message from L<App::karr::Git/delete_ref> instead of reporting a break that
did not happen (#119) -- an escape hatch that lies leaves the card locked for
every other agent with nobody left to look at it.

=head2 release

    my ( $ok, $msg ) = $lock->release( 12, 'agent@example.com' );

Gives back the lock on C<$task_id> (defaulting to L</task_id>) held by
C<$email>. Like L</acquire>, returns a two-element list for its ordinary
outcomes and only lets L<App::karr::Git/retry_contended>'s exhaustion
C<die> through:

=over 4

=item * C<(1, "released")> -- released, or already gone (nothing held,
already broken, or taken over after expiring). Not an error: release is
normally the tail end of a pick that already finished its work.

=item * C<(0, "locked by $current")> -- held by a different identity, left
untouched.

=back

The delete is itself a compare-and-swap against the holder read moments
before, so a lock that was broken and re-taken between the read and the
delete is not dropped out from under its new holder (#94).

=head1 SUPPORT

lib/App/karr/Role/BoardDiscovery.pm  view on Meta::CPAN

L<App::karr::Role::SyncLifecycle>.

=head2 require_board

    $self->sync_before;
    $self->require_board;

Refuses to go on when this repository has no initialized board. Every command
that writes to C<refs/karr/*> calls it, because without the check a C<karr
create> typed in the wrong directory silently seeded a partial board in an
unrelated repository -- and that partial board then locked C<karr init> out of
it permanently (#62).

It distinguishes the two ways of not having a board, because they call for
different things from the reader (#133):

=over 4

=item * nothing under C<refs/karr/> — "No karr board found. Run 'karr init' to
create one.", the sentence C<backup>, C<destroy>, C<materialize> and C<repair>
raise off L<App::karr::BoardStore/has_board_refs> for the same state;

lib/App/karr/Role/DependencyCheck.pm  view on Meta::CPAN

    my @warnings;
    for my $dep_id (@deps) {
        my $dep = $self->find_task($dep_id);

        # A deliberate divergence from the reference. kanban-md treats an id
        # that is not on the board as *satisfied*
        # (internal/board/filter.go:151-154): "Missing dependency IDs can occur
        # after legacy hard-deletes. Treat as satisfied so dependents are
        # recoverable via edit/cleanup." That reasoning is about not stranding a
        # card, and it is sound there, where an unsatisfied dependency makes the
        # card unpickable. Here nothing is blocked, so there is no card to
        # strand -- and a dependency pointing at an id that does not exist is
        # exactly the kind of thing whoever is about to start work wants told.
        if ( !$dep ) {
            push @warnings, sprintf
              'Warning: task %s depends on task %s, which does not exist on this board',
              $id, $dep_id;
            next;
        }

        next if $self->store->is_terminal_status( $dep->status );

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

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 block_reason => ( is => 'rw', predicate => 1, clearer => 1 );
has started      => ( is => 'rw', predicate => 1, clearer => 1 );
has completed    => ( is => 'rw', predicate => 1, clearer => 1 );
has extra        => ( is => 'rw', default => sub { {} } );
has file_path    => ( is => 'rw', predicate => 1 );


# Every frontmatter key karr models itself. Anything else read from a document
# goes to L</extra> instead of being silently dropped.
my @FRONTMATTER_FIELDS = qw(
  id title status priority created updated started completed
  assignee tags due estimate parent depends_on
  blocked block_reason claimed_by claimed_at class
);
my %IS_FRONTMATTER_FIELD = map { $_ => 1 } @FRONTMATTER_FIELDS;

use constant MAX_SLUG_LENGTH => 50;

# 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
# generated clear_X. This guards the load path: a document that carries an
# explicit null, or a value with no length at all, is normalized back to
# "unset" instead of lingering as has_X-true-but-empty.
#
# The empty case is the interop one. Every optional field here is `omitempty`
# in kanban-md's Go struct, so "absent" and "present but empty" are the same
# state on that side of the boundary -- while on this side Moo's predicate
# calls the second one set. A hand-written or third-party card carrying
# `claimed_by: ""` therefore looked claimed to `board`, blocked a
# require_claim move in `move`/`edit`, printed "Claimed:" with nothing after
# it in `show`, and counted as an engaged card toward karr-foundation's
# auto-block. #59 patched three of those readers one at a time; normalizing
# once here is the same fix for all of them, including the ones nobody has
# written yet (ticket #98).
#
# Emptiness is length, never truth: `0` and `"0"` are one character long and
# have to survive, which is the trap that gave ticket #78 its "body 0" row.
sub BUILD {
  my ($self) = @_;
  for my $attr (qw( assignee due estimate parent claimed_by claimed_at blocked block_reason started completed )) {
    my $clearer = "clear_$attr";
    my $has     = "has_$attr";
    next unless $self->$has;
    my $value = $self->$attr;
    $self->$clearer if !defined $value || !length $value;
  }
  $self->_normalize_lists;
  $self->_normalize_blocked;
}

# The two list-valued fields, guarded here for the same reason the optional
# scalars are cleared above: the parse gate is the only gate. A scalar where
# the list belongs -- `tags: urgent`, `depends_on: 1`, both only writable by
# hand or by a third tool, since every karr write goes through to_frontmatter
# -- used to pass construction and die mid-write at the dereference in
# to_frontmatter, as a raw Perl error carrying a source location (the #77
# class), and on the import path after refs had already started moving, which
# broke serialize_from's all-or-nothing promise (#70). A field whose whole

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

    next if ref $value eq 'ARRAY';
    if ( !defined $value || ( !ref $value && !length $value ) ) {
      $self->$attr([]);
      next;
    }
    die "Frontmatter field '$attr' must be a list"
      . ( ref $value ? '' : ', not a single value' ) . "\n";
  }
}

# karr up to 0.402 stored the blocking *reason* in `blocked` as free text;
# kanban-md has always had `blocked: bool` plus `block_reason: string`, and its
# YAML decoder refuses a string there outright ("cannot unmarshal !!str into
# bool" -- the task then vanishes from its board). This pulls a legacy document
# into the kanban-md shape on read, which is why no migration command is needed:
# the next write of that task emits the new shape (ticket #58).
#
# The invariant everything downstream relies on: has_blocked is true if and only
# if the task is blocked. "Blocked but false" is not representable, matching the
# `omitempty` on kanban-md's Blocked field.
sub _normalize_blocked {
  my ($self) = @_;
  return unless $self->has_blocked;
  my $raw = $self->blocked;

  # A boolean object from some other YAML loader.
  return $self->_set_blocked_flag($raw) if ref $raw;

  # YAML::XS loads `blocked: false` as the empty string, so this covers the
  # honest boolean false as well as an explicitly empty value.
  return $self->_set_blocked_flag(0) if !length $raw;

  my $bool = eval { App::karr::Config->parse_bool($raw) };
  return $self->_set_blocked_flag($bool) if defined $bool;

  # Not a boolean spelling, so it is a legacy reason string.
  $self->_set_blocked_flag(1);
  $self->block_reason($raw) unless $self->has_block_reason;
  return;
}

# Note the asymmetry with L</unblock>: a document that says `blocked: false`
# while still carrying a `block_reason` keeps that reason, because dropping it
# would be exactly the silent frontmatter deletion of ticket #69. Only an
# explicit unblock throws the reason away.
sub _set_blocked_flag {
  my ( $self, $value ) = @_;
  return $value ? $self->blocked(!!1) : $self->clear_blocked;
}

sub block {
  my ( $self, $reason ) = @_;
  $self->blocked(!!1);
  if ( defined $reason && length $reason ) {
    $self->block_reason($reason);
  } else {
    $self->clear_block_reason;
  }
  return $self;
}


sub unblock {
  my ($self) = @_;
  $self->clear_blocked;
  $self->clear_block_reason;
  return $self;
}


sub update_timestamps {
  my ( $self, $old_status, $new_status, $first_status, $config ) = @_;
  my $now = gmtime->datetime . 'Z';

  # Called on the class, is_terminal_status answers for the default board --

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

    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;
  $fm{blocked}      = $self->blocked      if $self->has_blocked;
  $fm{block_reason} = $self->block_reason if $self->has_block_reason;
  $fm{started}      = $self->started      if $self->has_started;
  $fm{completed}    = $self->completed    if $self->has_completed;
  return \%fm;
}



sub to_json_hash {
  my ($self) = @_;
  my $data = $self->to_frontmatter;
  # to_frontmatter only ever puts a true value here, and it has to become a
  # real JSON boolean rather than a Perl one: an older JSON backend would
  # encode Perl's !!1 as the number 1.
  $data->{blocked} = JSON::MaybeXS::true() if exists $data->{blocked};
  $data->{body} = $self->body if defined $self->body && length $self->body;
  return $data;
}

sub to_markdown {
  my ($self) = @_;
  my $yaml = yaml_dump($self->to_frontmatter);
  $yaml =~ s/\A---\n//;
  my $md = "---\n${yaml}---\n";
  my $body = $self->body;

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

    $md .= "\n" unless $body =~ /\n\z/;
  }
  return $md;
}


sub _parse_content {
  my ($class, $content) = @_;
  # The closing delimiter is anchored to the start of a line (/m), the way
  # kanban-md's splitFrontmatter scans for a literal "\n---\n". Without the
  # anchor a frontmatter value that merely *ends* in "---" -- `blocked:
  # waiting ---`, which YAML::XS dumps unquoted -- terminated the frontmatter
  # mid-line, and the truncated document then failed Task->new with "Missing
  # required arguments: id, title". Every command that loads the board hit it,
  # `delete` included, so the board could not be repaired with karr at all
  # (ticket #52).
  my ($yaml, $body) = $content =~ m{\A---\n(.+?)^---[ \t]*(?:\n(.*))?\z}ms
    or die "Invalid task format\n";
  $body //= '';
  $body =~ s/^\n//;
  # Every trailing newline, not one. The file path and the ref path disagreed

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

C<BUILD> rather than left as a predicate-true, empty-string claim (ticket
#98). Set together with L</claimed_at> by C<karr pick> and C<karr edit
--claim>; cleared together by the same commands' unclaim paths.

=head2 claimed_at

Timestamp paired with L</claimed_by>, stamped when a claim is taken. C<karr
pick> compares it against the board's configured C<claim_timeout> to decide
whether an existing claim has expired and the task can be picked again.

=head2 blocked

Boolean-only blocked flag; the invariant every reader relies on is that
C<has_blocked> is true if and only if the task is blocked (never "blocked
but false"). Only ever set through L</block>/L</unblock> or by parsing a
document -- writing C<< $task->blocked($reason) >> directly is exactly the
bug ticket #58 fixed. A legacy document with a free-text C<blocked> value
(karr up to 0.402) is migrated to the boolean-plus-L</block_reason> shape on
read.

=head2 block_reason

Optional free-text reason paired with L</blocked>, set via L</block> or a
parsed document. Deliberately not symmetrical with L</unblock>: a document
that says C<blocked: false> while still carrying a C<block_reason> keeps
that reason, because dropping it would be the same silent frontmatter
deletion ticket #69 fixed -- only an explicit L</unblock> throws the reason
away.

=head2 started

Full timestamp stamped by L</update_timestamps> on the first move out of the
board's first configured status, or backfilled to "now" when a task is
dragged straight to a terminal status without ever passing through
in-progress. Never reset by a later move, including a reopen -- the work did

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

(C<has_file_path>) but no clearer -- nothing in karr ever needs to forget
where a task was last written. Its absence is meaningful: a task that lives
only in C<refs/karr/*> and was never materialized to a file has no
C<file_path>, and L</save> called with no C<$dir> dies rather than guessing
one, directing the caller to L<App::karr::BoardStore/save_task> instead
(ticket #77).

=head2 block

  $task->block('waiting on the upstream API');
  $task->block;   # blocked, no reason recorded

Marks the task blocked and records the optional reason, keeping C<blocked> and
C<block_reason> consistent. This is the only supported way to set them: writing
C<< $task->blocked($reason) >> is what ticket #58 was about.

=head2 unblock

  $task->unblock;

Clears the blocked flag and any reason with it.

=head2 update_timestamps

  $task->update_timestamps( $old_status, $new_status, $first_status, $config );

Maintains C<started> and C<completed> across a status transition, the single
place that logic lives (kanban-md keeps it in F<internal/task/lifecycle.go>).
C<$first_status> is the board's first configured status; pass C<undef> when the
caller has no config to hand and only the terminal-status rules should apply.

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


Returns the task as a plain hash reference in kanban-md's frontmatter shape:
C<id>, C<title>, C<status>, C<priority>, C<created>, C<updated>, and C<class>
are always present, every other modelled field (C<assignee>, C<due>,
C<claimed_by>, and so on) only when its predicate is true, and whatever is
left in L</extra> fills in the rest verbatim.

This is the one place karr and kanban-md agree on what a frontmatter document
looks like. C<to_markdown> feeds the result straight to L<YAML::XS> for the
on-disk and ref form; L</to_json_hash> layers a C<body> key and a real JSON
boolean for C<blocked> on top of it for C<--json> output; C<karr board --json>
uses it directly, which is why a board column carries no card bodies.

A modelled field always wins the slot it owns, and a field that has since
been cleared cannot be resurrected by a stale copy in C<extra> either --
every key karr models is stripped out of C<extra> before the modelled values
are laid on top of what remains.

=head2 to_json_hash

  my $data = $task->to_json_hash;

Returns the task as a plain hash reference ready for JSON encoding: the
frontmatter fields from L</to_frontmatter> plus a C<body> key when the task has
a non-empty body. Used by every command that emits whole tasks as C<--json>:
C<show>, C<list>, C<pick>, C<handoff>, C<materialize>, and C<import>. C<list>
joined that set late -- it built its payload from L</to_frontmatter> alone and
therefore dropped every body until ticket #129.

C<blocked> comes back as a JSON boolean, so an agent parsing C<--json> sees the
same C<true> kanban-md emits and never the free-text reason it used to get
there (ticket #58). A body of C<"0"> is included, because emptiness is tested by
length and not by truth (ticket #78).

=head2 to_markdown

  my $text = $task->to_markdown;

Renders the task as the Markdown-plus-YAML-frontmatter document stored in
C<refs/karr/tasks/*/data> and written by L</save>: L</to_frontmatter> dumped

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


```bash
karr edit ID --title "New title"
karr edit ID --priority high --add-tag urgent
karr edit ID --add-depends-on 2,3            # append dependency ids (no duplicates; ids must exist, no self-reference)
karr edit ID --remove-depends-on 4           # absent ids are a no-op (cleanup after a deleted dependency)
karr edit ID --body "New description"
karr edit ID -a "Appended note"              # append to body
karr edit ID --claim agent-1                 # claim
karr edit ID --release                       # release claim
karr edit ID --block "Waiting on API"        # mark blocked
karr edit ID --unblock                       # clear blocked
```

An unknown or non-numeric id given to `--depends-on`/`--add-depends-on` rejects
the whole invocation before anything is written (usage error, exit 2); a
self-reference (`karr edit 5 --add-depends-on 5`) fails only that id, the rest
of the batch proceeds, and the command exits 1. Taking up a card whose
dependencies are unfinished warns on move/pick but is never blocked.

### Delete task

```bash
karr delete ID --yes                         # skip confirmation
```

### Archive task

```bash

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

Shows tasks grouped by status with WIP utilization.

### Pick next task (multi-agent)

```bash
karr pick --claim agent-1                    # pick highest priority available
karr pick --claim agent-1 --status todo --move in-progress
karr pick --claim agent-1 --tags backend
```

Atomically finds and claims the next available task. Respects claim timeouts, blocked state, and class-of-service priority ordering (expedite > fixed-date > standard > intangible).

### Unlock a stuck task

```bash
karr unlock                                  # list the pick locks currently held
karr unlock ID                               # break one
karr unlock --all                            # break all of them
```

`karr pick` takes a lock ref and gives it back inside the same command, so normally there is nothing here to see. An agent that dies mid-pick leaves one behind. Locks expire on their own after `lock_timeout` (default `5m`, board config); this is how ...

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

karr config get foundation.enabled           # -> 0 or 1
karr config set foundation.enabled false     # true/false, yes/no, on/off, 1/0
karr config set foundation.reason "why"
```

### Context (board summary for embedding)

```bash
karr context                                 # print markdown summary
karr context --write-to AGENTS.md            # create/update file with sentinels
karr context --sections blocked,overdue      # filter sections
karr context --days 14                       # lookback for recently-completed
karr context --json                          # JSON output
```

Generates a markdown summary with sections: In Progress, Blocked, Overdue, Recently Completed. Uses `<!-- BEGIN kanban-md context -->` / `<!-- END kanban-md context -->` sentinels for in-place updates.

### Skill management

```bash
karr skill install                           # install skill for detected agents
karr skill install --agent claude-code       # install for specific agent
karr skill install --global                  # install globally (~/)
karr skill install --force                   # force reinstall
karr skill check                             # check if installed skills are current
karr skill update                            # update outdated skills

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

is kept separately in `refs/karr/meta/next-id`.

## Decision tree: which command?

1. **Need a board?** → `karr init`
2. **New work item?** → `karr create "Title" --priority high`
3. **What's on the board?** → `karr board` or `karr list`
4. **Starting work?** → `karr pick --claim NAME --move in-progress`
5. **Done with task, hand to review?** → `karr handoff ID --claim NAME --note "reason"`
6. **Done with task, close it?** → `karr edit ID --release && karr move ID done`
7. **Blocked?** → `karr edit ID --block "reason"`
8. **Need details?** → `karr show ID`
9. **Soft-delete?** → `karr archive ID`
10. **Board snapshot for agent context?** → `karr context --write-to AGENTS.md`
11. **Check/change config?** → `karr config` / `karr config set KEY VALUE`
12. **Install agent skills?** → `karr skill install`
13. **Need a full board snapshot?** → `karr backup` / `karr restore --yes`
14. **Need shared non-task workflow data?** → `karr set-refs` / `karr get-refs`
15. **Board should never be drained by an automation host?** → `karr disable --reason "why"`
16. **Need to remove the board completely?** → `karr destroy --yes`

t/04-handoff.t  view on Meta::CPAN

  );
  $task->save($tasks->stringify);

  my $loaded = App::karr::Task->from_file($tasks->child('003-block-test.md'));
  $loaded->status('review');
  # Ticket #58: the flag and the reason are two fields, as in kanban-md.
  $loaded->block('waiting for feedback');
  $loaded->save;

  my $after = App::karr::Task->from_file($tasks->child('003-block-test.md'));
  ok $after->blocked, 'blocked flag set';
  is $after->block_reason, 'waiting for feedback', 'blocked with reason';
};

done_testing;

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

  $tasks->mkpath;
  DumpFile($board->child('config.yml')->stringify, App::karr::Config->default_config);

  # Create tasks in various states
  App::karr::Task->new(
    id => 1, title => 'Active Task', status => 'in-progress',
    priority => 'high',
  )->save($tasks->stringify);

  App::karr::Task->new(
    id => 2, title => 'Blocked Task', status => 'in-progress',
    priority => 'medium', blocked => 'waiting on API',
  )->save($tasks->stringify);

  App::karr::Task->new(
    id => 3, title => 'Done Task', status => 'done',
    priority => 'low', completed => gmtime->strftime('%Y-%m-%d'),
  )->save($tasks->stringify);

  # Load and verify tasks
  my @files = sort $tasks->children(qr/\.md$/);
  is scalar @files, 3, 'three task files created';

  my @loaded = map { App::karr::Task->from_file($_) } @files;
  my @in_progress = grep { $_->status eq 'in-progress' && !$_->has_blocked } @loaded;
  is scalar @in_progress, 1, 'one active non-blocked task';

  my @blocked = grep { $_->has_blocked } @loaded;
  is scalar @blocked, 1, 'one blocked task';
};

subtest 'write-to with sentinels' => sub {
  my $dir = tempdir(CLEANUP => 1);
  my $file = path($dir)->child('AGENTS.md');

  # Write initial content
  my $context = "<!-- BEGIN kanban-md context -->\n## Board: Test\n<!-- END kanban-md context -->\n";
  $file->spew_utf8("# My Agents\n\n" . $context);



( run in 3.701 seconds using v1.01-cache-2.11-cpan-788537b7465 )