App-karr
view release on metacpan or search on metacpan
the flag is `--wait`.
- Parent/subtask links (kanban-md's `--parent`) are a documented
non-goal, not a gap: the `parent` field round-trips through
`karr import` unchanged, but no karr command sets, filters,
renders or sorts it. Relatedness between cards is `depends_on`
(local ordering) and `needs` (cross-board). CONTEXT.md and
README's stored-task-shape section now say so.
0.600 2026-08-31 01:16:28Z
- Karr writes its own ticket ids as `kNNN` in prose from now on,
never as a bare `#NNN`. A `#`-number reads as an issue on whichever
bug tracker the reader's repository carries, and karr tracks its
work on its own board rather than in one, so `k243` in a changelog
line or a commit message pointed at the wrong system for every
reader but the author. The convention lives in the shared skills so
commits and Changes entries use one spelling; the entries below
already do.
- BREAKING: the bundled agent skill installs as
`kanban-issues-karr-cli`, not as `karr`. `karr skill install`
and `karr init --claude-skill` now write
`.claude/skills/kanban-issues-karr-cli/SKILL.md`. The dist shipped
the skill under one name while this repository briefed its own agents
under the other; the longer name is the more findable. A project
still holding the old `.claude/skills/karr/` keeps it -- nothing
removes it for you, so delete it after updating or the agent reads a
skill nothing maintains any more.
- The distribution is licensed under the Artistic License 2.0 instead
of the same terms as Perl 5 itself; the dual grant carried a copyleft
option this project does not want. The LICENSE file ships in the
repository.
- Board commands report errors an agent can act on (k263, k264).
"Task 999 not found", shared by `move`, `edit`, `delete`, `archive`,
`handoff` and `show`, now ends `on this board:` with `karr list
--compact` on its own last line; `karr wibble` ends on a copyable
`karr --help`; and an error about a missing or malformed option ends
with the invocation that would have worked, built from the words the
caller typed -- `karr move 79 in-progress` answers with `karr move 79
in-progress --claim NAME` under it. That line comes last, because
agents read karr through `tail -n`, and only where at least one word
of it is the caller's own. `move`, `needs`, `import`, `materialize`,
`repair`, `edit` and `archive` are covered, as are the option-parse
errors karr does not word itself. Exit codes are unchanged and
`--json` keeps its single-line `error` field.
- `karr list` gains `--limit`/`-n`, `--class`, `--blocked`/
`--not-blocked` and `--unclaimed`, and `--sort title` (k237, k252).
The cut is the last stage, after filter and sort, so `--sort priority
-n 5` is the five most urgent cards and it applies to `--json` and
`--compact` alike; `0` is no limit and a negative count is a usage
error. An unknown `--class` names the board's own classes and exits 2
rather than printing an empty list, and `--blocked` with
`--not-blocked` is refused. `--unclaimed` lists the cards no
live claim holds -- `claimed_by` unset, empty, or a claim past
`claim_timeout` -- which k237 had taken for a duplicate of
`--claimed-by` but asks a different question; it shares one claim
test with `karr pick` (`Role::ClaimTimeout::claim_held`) so the two
cannot disagree about what is free (k198's shape), and passing it
with `--claimed-by` is a usage error. `--sort title` compares
case-insensitively as kanban-md does, ties break on `id`, and the
comparison is on characters and not collated -- a non-ASCII title
sorts behind every ASCII one, a documented non-goal rather than a gap
(a collating comparator would need a locale two agents need not
share).
- `karr pick` ranks two `fixed-date` cards by the sooner due date
before priority, matching kanban-md's `compareDue`: the one
class of service that exists because a date rather than an urgency
decides was the one place the date was ignored. The exception is
narrow and the reference's own -- only where both candidates carry
`fixed-date`, and only between them.
- Invalid and no-op invocations are refused or short-circuited instead
of resolved in silence (k231, k235). Five contradicting invocations
-- `edit --block X --unblock`, `edit --body X --append-body Y`,
`create TITLE --title OTHER`, `move ID STATUS --next` and `move ID
--next --prev` -- exited 0 with one half quietly discarded; all five
now exit 2 through `usage_error` (`move ID STATUS --next` is the one
step away from kanban-md, which resolves it silently the other way),
and the refused `create` is checked before an id is allocated.
A `move` to the status a card already has, and an `edit` naming no
field, no longer write at all: both went through the mutation path,
stamping `updated` and logging a move that never happened -- the field
karr-foundation's drain reads to tell a stuck card from a worked one.
The no-op move exits 0 with `Task N is already at X` and `--json`
gains a `changed` key; an `edit` naming no field is an empty request
and exits 2 (ADR 0002). `move ID SAME --claim NAME` still writes,
because the claim is a change.
- `karr board` and `karr context` leave archived cards out entirely and
ask the board which status is terminal instead of looking for `done`
(k229, k234). `board` counted archived cards in the footer total and
in `blocked` and rendered them as a column -- so this board read `241
tasks` over ten rendered cards -- and on a board whose last column is
`shipped`, `--done` did nothing; the hidden-count hint now names the
real column (`2 tasks (1 shipped hidden)`) and archived cards are gone
from all three output modes. `context` had been filtering every
terminal status where kanban-md filters only `archived`, so finished
work went missing from the header total and a card blocked before
`done` fell out of the `blocked` count -- the two tools maintain one
context block in a shared host file and have to put the same numbers
in it. Filed cards are read with `karr list --archived` and
`karr show ID`; default boards print the same bytes as before.
- An appended note is a paragraph of its own, and `karr edit` takes the
`-t`/`--timestamp` flag `karr handoff` already had. `karr edit
ID -a` and `karr handoff --note` joined the new text with a single
newline, which Markdown folds away; they join with a blank line now,
through one `Task/append_body`, trimming trailing newlines first, as
kanban-md's `AppendBody` does. An empty body still gets no leading
blank line, existing cards are not rewritten, and `--timestamp` alone
is refused as an empty request.
- The board config no longer carries per-class WIP limits.
`default_config` gave every `expedite` class `wip_limit: 1` and
`bypass_column_wip: 1` and `validate` checked them, but no command
ever read either -- karr has no WIP limits, per-status or per-class.
spelled out in the POD, along with the fact that one card can be in
both. The command also stops implying an hour precision its data does
not have: boards written before ticket k68 (karr 0.403) carry
day-granular `started`/`completed` stamps, bare `YYYY-MM-DD` read as
midnight, which is where every negative duration comes from, and on such
a board an average printed to the hour is finer than what it rests on.
On karr's own board 51 of 138 lead samples are negative, averaging
-10.1 hours; discarding them would raise the printed average from 6h 39m
to 16h 28m, which is why the figure is reported with the 51 beside it
rather than tidied up with the evidence removed. No data migration:
`karr repair` is untouched and the stored stamps stay exactly as they
are.
- `karr skill install` and `karr skill update` write the target SKILL.md in
place instead of replacing it (ticket k142). Path::Tiny's `spew_utf8`
writes a temp file and renames it over the destination, so the path it
wrote came back on a new inode -- right for an ordinary file, wrong for a
skill file: skills are shared between projects as a hardlink chain, one
inode behind the same relative path in dozens of checkouts, and the
rename broke the updated path out of its chain. That one project got the
new skill, all the others kept the old inode with the old text, and the
link count dropped with nothing said. Found during agent setup in an
unrelated repository, where the workaround was piping `karr skill show`
into a shell redirect. The write now truncates the existing file and
writes through it, so every link sees the update; it is still
Path::Tiny's character-level UTF-8, so the encoding boundary is unmoved.
A target that does not exist yet is still created and a symlinked target
is still written through. A target that cannot be opened for writing at
all -- a read-only SKILL.md, which the rename handled because it only
needed a writable directory -- is still updated by replacement rather
than turned into a failure, but that is the one case left where a chain
cannot survive, so it is now reported instead of done silently.
- `karr init --claude-skill` writes .claude/skills/karr/SKILL.md in place
too (ticket k145). It installs the very file `karr skill install --agent
claude-code` installs, and it still did so with the `spew_utf8` that
ticket k142 had just removed one command over: a temp file renamed over
the target, so the path came back on a new inode. Where a skill file is
one link of a hardlink chain shared between projects, that broke this
project out of the chain and left every other one on the old inode with
the old text. Both commands now go through one shared implementation â
App::karr::Role::SkillFile â rather than a copy each, because the copy
is how the rule came to be right in one place and wrong in the other; it
carries the in-place write, the fallback for a target that cannot be
opened for writing at all, and the warning that says so when a chain was
there. `karr init` still reports a directory it cannot create as "Could
not create .claude/...", which is what actually failed, and `karr skill`
behaves exactly as before.
- `App::karr::Role::TaskMutation` now declares the five methods it calls
on its consumer (ticket k141): `git` and `store` from
`Role::BoardDiscovery`, `save_task` and `log_task_write` from
`Role::BoardAccess`, `json` from `Role::Output`. It declared nothing at
all, and composed cleanly into anything, which is the state k128 found
`Role::DependencyCheck` in â so a future command reaching for
`update_task_guarded` would have inherited a compare-and-swap loop whose
collaborators nobody had checked for, and learned about it from inside
the callback as a "Can't locate object method". No command changes: all
five on the mutation path already compose both supplying roles, which is
what hid the gap. The ticket proposed six names; `check_claim` is not
one, because it comes from `Role::ClaimTimeout`, which this role
composes, exactly as `check_dependencies` comes from
`Role::DependencyCheck`. Requiring either would never fail â Role::Tiny
installs a role's methods into the consumer before checking its
requires, so the check finds what the composition just put there. `json`
is now declared twice, here and on `Role::DependencyCheck`; that is
deliberate, since a role that lets one it happens to compose declare a
collaborator on its behalf is how this gap opened.
- `App::karr::Role::ClaimTimeout` now declares the one method it calls on
its consumer (ticket k144): `store`, from `Role::BoardDiscovery`. It
declared nothing at all and composed cleanly into anything, which is the
state k128 found `Role::DependencyCheck` in and k141 fixed for
`Role::TaskMutation` â so a consumer without `store` was still handed
`check_claim`, karr's one claim-ownership rule, and would have found out
from inside a mutation as a "Can't locate object method", on the one run
where a task was actually claimed by somebody else. No command changes:
all seven that compose the role, directly or through
`Role::TaskMutation`, already bring `store` along via
`Role::BoardAccess`, which is what hid the gap. One name is the whole
list â the role's four other calls reach subs defined in the role itself,
and unlike `Role::TaskMutation` this one composes no role at all, so
nothing arrives by composition either. Requiring any of them could never
fail: Role::Tiny installs a role's methods into the consumer before
checking its requires, so the check finds what the composition just put
there. With this, the three roles on the mutation path all say what they
call.
- `karr repair` gained a second migration (ticket k138): it raises a
`started` stamp that precedes its own card's `created` up to that
`created`. karr wrote `started` as a bare date until k68, which reads as
midnight and therefore lands before any card filed and begun on the same
day â 75 of 138 cards on karr's own board. Both migrations are reported
and applied together but kept apart in the output, since a board can
need either without the other, and neither bumps `updated`: a migration
is not an edit, and stamping every card it rewrites would destroy the
history it is repairing. Only the pre-k68 bare-date shape is clamped; a
`started` that precedes `created` while carrying a time of day is a
different and unknown fault, so it is reported and left alone rather
than having its evidence erased. What the clamp costs is stated by the
command rather than left to be discovered: a clamped card asserts zero
queue time, which is false for one filed in the morning and picked up at
night, and nothing on it marks the stamp as having been day-granular any
more â the rewrite cannot be undone from the data. The dry run says how
many cards that is before `--yes` applies it. `repair` also no longer
returns early when the encoding is already current, since otherwise the
clamp could never run on a board written by a current karr; `up_to_date`
in the JSON keeps answering for the encoding migration alone, and says
so.
- `App::karr::Role::DependencyCheck` is split in two (ticket k137). It
carried both halves of dependency handling under one name, with two
different contracts: parsing and validating `--depends-on` arguments at
set time, and warning at move time that a card's dependencies are
unfinished. `Cmd::Create` composed the whole thing for the first half
alone, which is why k128 could not put `json` in the role's `requires` â
`create` has no `--json`, and requiring it would have refused a consumer
that never reaches the reporting half. The set-time half is now
`App::karr::Role::DependencyArgs`, named beside the existing
`Role::CliArgs` for the same reason: it turns command-line values into
validated ids. Both halves now declare every method they call on their
consumer, `json` included. Only `Cmd::Create` and `Cmd::Edit` change
what they compose; `Cmd::Pick` and `Role::TaskMutation` keep the
reporting half under its old name, so external `L<...DependencyCheck>`
references stay correct. `Cmd::Edit` turned out to be getting
`parse_dependency_ids` by accident of the two halves sharing a role, and
now names `DependencyArgs` itself.
- New command `karr metrics` (ticket k126), the last kanban-md feature
karr did not have: throughput over fixed 7- and 30-day windows, average
and C<karr repair>, which it had never mentioned, spells
C<karr agent-name> the way the command table does, and no longer
describes C<karr handoff> as moving to a literal C<review> â since
ticket k102 the target is the board's review column, or its last
non-terminal column on a board that has none. This applies to
F<share/claude-skill.md>, the copy C<karr skill install> writes into
other projects, so an agent set up by karr gets the corrected text
(ticket k117 tracks that this copy and the one in this repository are
kept in step by hand).
- App::karr::Git now resolves every path it hands git from the work tree
root, on both routes into is_tracked_under (tickets k113 and k114). The
string comes out of _relative_to_root, which measures from the root, and
libgit2 resolves it that way by itself â but the `git ls-files` fallback
ran as `git -C ->dir`, and a pathspec is resolved against the process
cwd. Build the class on a subdirectory, as its own SYNOPSIS shows with
`dir => '.'`, and it asked about `subdir/tasks` while the caller asked
about `tasks`; a pathspec that matches nothing exits 0 with no output,
which reads back as "not tracked", so a project that owns `tasks/` would
be told it does not â the symptom ticket k89 removed, through a different
door. The CLI is now pinned to the root, which the transport verbs cannot
tell apart. The root itself is `.`, a pathspec git understands but not a
path the index can hold â entries are stored as `tasks/a.md`, never
`./tasks/a.md` â so the native route answered 0 for a repository full of
tracked files; at the root the question is now whether the index holds
anything at all, which is what `ls-files -- .` answers there too. No
karr command changes behaviour: every is_tracked_under call goes through
the store's Git, which App::karr::Role::BoardDiscovery builds at the
repository root, and none of them passes the root as the path. Both were
latent, and each was a wrong answer rather than a failure â the kind that
would have surfaced as the answer depending on whether libgit2 was
available to ask.
- App::karr::Git::is_tracked_under now reads the index natively, through
Git::Native::Index, and only falls back to `git ls-files` when libgit2
declines to answer (ticket k107). That question decides whether `karr
init` and `karr materialize` may claim `tasks/` and `config.yml` in
.gitignore, and it used to be asked through the git CLI unconditionally
â not as a fallback, but because the Git::Native of the day exposed no
index at all. With no `git` on PATH the run simply failed, the answer
came back "not tracked", and both commands wrote the entries over paths
the project already tracks, undoing ticket k89 in that configuration.
The native route needs no `git` binary, so that configuration now
answers correctly; the CLI remains for an index libgit2 cannot read,
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
k108). 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`,
`edit`, `delete`, `archive`, `handoff`, `pick` and `unlock` (ticket k105,
finishing k38). These were the worse half of that family, because the two
shadow builtins: a later `sub localtime` or an attribute of that name on a
command class would have fought an inherited Time::Piece export, and the
failure would have read as a core function misbehaving. Time::Piece is not
a drop-in for the usual cure â replacing the builtins is its whole point â
so the call sites were decided one at a time instead of swept.
ClaimTimeout keeps the module and spells its one live call
`Time::Piece::gmtime()`, because `_claim_expired` needs the overloaded
object and the builtin would hand that subtraction a string; TaskMutation
never asked for the time at all and drops the module, since the lifecycle
stamps are written by App::karr::Task. Nothing called either as a method,
so no behaviour changes, and t/121-role-import-leakage.t now runs with an
empty allow-list.
- Finished the sweep that stopped karr's own source locations reaching the
user (ticket k77). `croak` appends " at Some/Module.pm line 42." even to a
message that already ends in a newline, so anyone who ran `karr list`
outside a repository was told "Not a git repository. karr requires Git."
and then handed the file and line of the builder that said so; every
remote failure ended with a line number in whichever `Cmd/*` had called
the sync; and `karr-foundation` reported a broken config the same way.
Those, plus the pipe/fork/log-open failures in the foundation runner, now
go through `App::karr::Error::user_error` and print the message alone. The
four commands that let a Path::Tiny error out raw â `karr restore
--input` on an unreadable file, `karr backup --output` and `karr context
--write` into a directory karr may not write, `karr init --claude-skill`
into an unwritable `.claude` â now name the path the user typed and the
reason the OS gave, and nothing else. Two errors deliberately keep their
call site, because there it is the useful part: saving an unpersisted
ref-backed task, which is a programming error, and `croak` in
App::karr::Foundation's YAML report, whose parser message names its own
document, line and column and is passed through whole rather than reduced
to one line.
- A failed sync now shows git's error once instead of twice. The message
that ended the command embedded another copy of the multi-line error that
had already been printed the moment it happened â so one failed pull put
the same "does not appear to be a git repository" block on the screen
twice, and `--quiet`, which suppresses the retry banners and never the
errors (that is deliberate, ticket k27), made no difference to the
duplicate. `sync_before` now ends on the verdict alone, the way
`sync_after` always has: "Pull failed after 3 attempts. Nothing was
changed. / Run 'karr sync' to retry." A cause that *changes* between
attempts is still reported each time.
- App::karr::Role::BoardDiscovery and App::karr::Role::SyncLifecycle no
longer compose their imports into the ~20 command classes that consume
them (ticket k38). A Moo::Role copies every sub in its package into its
consumers, imported ones included, so `use Path::Tiny;` and
`use Carp qw( croak );` in a role made `$cmd->path(...)` and
`$cmd->croak(...)` callable on every command. Nothing called them, so
nothing was broken â but the first command class to want an attribute
named `path` would have fought an inherited Path::Tiny constructor for it,
silently. Both roles now load what they need with an empty import list and
qualify the call, which is what App::karr::Role::Output and
App::karr::Role::TaskMutation already did. The `use Time::Piece;` that was
out of scope here went with ticket k105 above. Still leaking, and not
karr's to fix: MooX::Cmd::Role composes `croak` into every command class
from upstream.
- Fixed an ordinary kanban-md round trip silently switching a disabled
board back on. kanban-md rewrites `config.yml` the moment it loads one â
it migrates the schema version and re-serialises the file from its own
structs â so afterwards every key that schema does not know is gone from
the view, karr's `foundation` among them. `karr import --yes` then
replaced `refs/karr/config` with what was left, and a board switched off
with `karr disable` came back enabled, with karr-foundation resuming
agent runs on it; nothing warned at any step. Import now reconciles the
view against the board config instead of replacing it â the view speaks
for the keys it carries and karr models, and every other key keeps what
the board already said. `lock_timeout`, karr's other own key, was being
( run in 1.524 second using v1.01-cache-2.11-cpan-85d3896f969 )