App-karr
view release on metacpan or search on metacpan
means "no per-run timeout and no drain budget" matching the
documented intent, and a positive value bounds the drain as
before (#165). `_discover_repos` deduplicates by canonical path,
so a repo reachable through both `dirs:` and `scan:` is processed
exactly once per tick: realpath (with absolute as fallback) keyed
by path, first-seen order preserved so an explicit `dirs:` entry
wins over a `scan:` hit (#166). A pull that refuses no longer
aborts the whole foundation run: `_process_repo`'s pull is now
wrapped in the same try/catch that already protects the other
per-repo steps (`_drain_repo` below it, `_process_repo` itself
from #162), so a refusal from the wholesale-wipe guard, the
board-identity guard, or the unapplied-refs guard warns and lets
the run continue â and the board whose pull refused is not then
processed as if it were up to date (#168).
- Four fixes in karr's character/octet boundary and refs-backed
storage guarantees (tickets #155, #156, #157, #167). `karr restore`
is now atomic across its write phase: `replace_board_refs` snapshots
every `refs/karr/*` OID and every ref the snapshot is about to
introduce before the first `_write_ref_oid` call, and any die out
of the write loop unwinds every ref that landed â restoring the
original OID for refs that existed, deleting refs the snapshot
managed to create â so the board reads back exactly as it did
before the failed restore. `Cmd::Restore`'s POD promise ('a snapshot
karr cannot apply ... is refused with the board exactly as it was')
is now true for the directory/file name conflict that previously
half-applied, and for the CAS-exhaustion path that previously
half-applied without any manual editing at all (#155). The
activity log no longer loses entries under concurrency: log_entry
wraps its read-and-write in `write_ref_cas` + `retry_contended`,
matching `save_task_cas` and `allocate_next_id_ref`, so the
existing CAS plumbing handles contention transparently and a
board running N parallel `karr create` writes N log entries
(#156). `git_user_name` and friends no longer leak libgit2's
octets into karr's character strings: `Git.pm:_config_string`
and `_run_git`'s captured stderr decode through `from_octets`,
so a non-ASCII `user.name` is no longer written double-encoded
into the log ref and `karr repair` does not need to undo it on
read (#157). `%ENV` is now an octet crossing `App::karr::Encoding`
owns: two new helpers, `to_octets_for_env` and
`from_octets_from_env`, match the POD style of the existing
helpers and delegate to the canonical codec, and the three
`Foundation/Runner.pm` writes go through `to_octets_for_env` â
so the 'Wide character in setenv' warning on a non-ASCII prompt
is gone, and the house rule that Encoding owns every crossing
is complete (#167).
- Three board commands no longer treat a value the user did pass as
if it had not been given (tickets #151, #152, #153). `Cmd/Log.pm`
refused `--last < 1` only via truth, so `karr log --last 0` dumped
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-
builtin command where the real agent was the shell's child, not the
shell (#148); an agent that closed its stdout before max_runtime
elapsed, where the runner fell through to a bare blocking waitpid
that held `.karr.lock` forever (#161); and a SIGTERM/INT/HUP to
foundation mid-drain, where the agent was reparented to init and
`.karr.lock` named a dead pid the next tick read as free (#163).
The runner wraps every agent in its own process group with
`setpgid(0,0)` in the child and `setpgid($pid,$pid)` in the parent
(the second call wins the fork race idempotently); the timeout,
SIGTERM and SIGKILL all signal the group with a negative pid, so
the shell, the agent and any grandchildren the agent forked all
receive the kill. `max_runtime` is now enforced independently of
IO activity by a SIGALRM handler that closes the read end of the
pipe, and the post-EOF wait is a deadline-aware WNOHANG poll that
falls through to the SIGTERM/SIGKILL/reap path when the wall clock
beats the child. Foundation installs a SIGTERM/INT/HUP handler for
the lifetime of `run()` that kills the agent's group, force-releases
the lock, and `POSIX::_exit(128 + signum)` â the conventional shell
exit shape, so systemd/cron see a signal-death exit and an operator
reading the log does not need a special case for "killed cleanly
mid-drain".
- `.karr.lock` is now a `flock(2)` on an open file descriptor the
foundation keeps for the lifetime of the lock, not an advisory pid
that two ticks could each write their own value into (#162). Two
ticks that overlap â the normal case, since a drain may run for
`max_runtime` (default 1800s) while cron fires every few minutes â
race on the file: the second tick gets `EWOULDBLOCK` from
`LOCK_EX|LOCK_NB` and returns immediately, without overwriting the
existing pid. `_release_lock` closes the open fd (closing drops the
flock) and only unlinks the file if the recorded pid still matches
`$$`, so a pid-recycled foundation cannot unlock its successor's
lock. `_lock_held` is the flock check, not a `kill(0,$pid)` against
a recorded pid the foundation wrote itself â a stale lock whose
holder died is held=false and a fresh tick takes over without
manual cleanup. Path::Tiny's `slurp_utf8` does an internal blocking
flock that hangs forever when the same process already holds one,
so the metadata read is a raw `sysread` loop.
- An agent killed by a signal is now booked as `128 + signum`, not as
a clean exit 0 (#164). The runner used to compute `$exit_code =
$? >> 8` â the high 8 bits, which are 0 for any child that died
from a signal. The OOM-killer, an external SIGTERM, a SIGSEGV, and
any other signal-death shape were all booked as a clean run:
`last_error` stayed unset, the cooldown that exists to back off
after a machine-killing agent never engaged, and the next cron
tick re-launched at full rate. The fix reads both halves of `$?`:
signal death becomes `128 + signum` (the shell convention, so
SIGTERM=143, SIGKILL=137, SIGSEGV=139, SIGINT=130); a normal exit
falls through to `( $? >> 8 ) & 255`. The timeout path's exit code
- karr-foundation no longer throws away a successful agent run because of
something it printed (ticket #160). The common-error scan ran on every
run before anything asked whether the run had worked, over the whole
transcript, against bare substrings â network, quota, credentials, 401,
403, 429, 503. An agent working a karr board prints the board, so a
backlog line reading "retry the network fetch on 503" matched, and so
did a diffstat of 403 changed lines. The drain aborted, the cards the
agent had just moved were credited to nobody, and the cooldown climbed
1m, 2m, 4m ⦠64m without ever resetting, because the next run printed
the same words: a healthy board throttled to one discarded run per hour.
What a run did is now asked before what it printed. A run that exited 0
and moved the board is progress whatever scrolled past, and is never
reclassified by its own output; the scan is evidence only where there is
none other, a run that moved nothing â which is what a rate-limited or
unauthenticated agent looks like. A pattern seen in a run that did move
the board is noted in `.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"), never on its own, 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, a line number or a commit hash. Genuine
failures reported by an agent that still exits 0 keep triggering the
backoff, which is what the scan is for. A board's own `error_patterns`
are unchanged: plain case-insensitive substrings.
- `.karr.state` no longer keeps a `last_error` from a run three cooldowns
ago sitting next to `last_exit: 0` with nothing to explain the pair
(ticket #160). `last_error` describes the last run and is dropped by the
next run that is not a common error. Where the pair is real â an agent
that reports a rate limit and still exits 0 â it is now said out loud:
`.karr.log` records "COMMON-ERROR rate limit â agent exited 0, run
discarded", and `karr-foundation --status` names the reason beside the
wait ("cooldown 240s (rate limit)").
- Fixed data loss when a pull could not write a ref (ticket #154). The
apply step of the reconciliation used an unretried ref write whose
failure nobody checked, so a ref whose `.lock` file was held â by
another karr mid-write, or left behind by one that was killed â was not
applied, while the `refs/karr-remote/` mirror was advanced as if it had
been. The next reconciliation then read the stale local ref as unpushed
work and the forced, pruning push wrote it over the remote's newer card,
in every clone, at exit 0. Those writes now retry on the same terms as
every other ref write in `App::karr::Git`, a ref that still cannot be
applied leaves the mirror at its pre-fetch value so the next sync
decides it again, and the pull fails with a non-zero exit naming the ref
instead of proceeding to the push. The same fix covers a remote deletion
that could not be applied (which used to be pushed back as a
resurrection), a conflict whose local version could not be parked (the
local version is now kept rather than replaced), a mirror rollback
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
stale claim is what `claim_timeout` and `karr unlock` are for. Where that
evidence is missing altogether â an agent command that never calls
`karr`, an unreadable log â foundation now auto-blocks nothing rather
than guess: a drain that ends on its iteration cap costs an iteration,
blocking the wrong card costs somebody their work. The ownership test is
repeated at the write itself, which is the only place foundation mutates
a board, so a future caller inherits the guarantee instead of having to
remember it.
- karr-foundation no longer splices environment values into the agent
command string before `/bin/sh` parses it (ticket #159). `PROMPT`,
`KARR_REPO` and `KARR_ROLE` are exported into the child's environment
and the shell expands them, as it already could. Previously a prompt's
backtick spans and `$(...)` â board content, written in Markdown â were
executed as shell commands in the board's own directory, and the agent
then received an instruction nobody wrote; and the substitution reached
inside single quotes, where sh guarantees a literal, so the documented
output-shaping technique broke silently (`awk '{print $2}'` arrived at
awk as `'{print }'`). Every variable a command template could reference
before still expands, the `${VAR}` form included. The START line in
`.karr.log` now records the command template â the exact string handed
to `/bin/sh` â instead of the substituted result, and so no longer
copies environment values, a wrapper's API key included, into a
plaintext log.
- karr-foundation can no longer start an agent and then walk away from it
(ticket #147). `App::karr::Foundation::Runner` opened `.karr.log` after
the fork, so a log it could not open was reported with the agent already
exec'd, and that `user_error` came before the parent's own `waitpid`.
Not fatal to the run, which is what made it expensive: `_run_command` is
called from the drain loop, which `_process_repo` catches per repo and
then releases the board's lock anyway, so every affected board was left
with a live, unwatched agent and a lock file saying nobody was running â
and the next tick would start a second one on top of it. The log is now
opened before the fork, which turns an unwritable log into a refusal with
nothing started: the same answer the foundation's own `_append_log` for
the START line already gives one call earlier, and the reason that window
needed a race to reach at all, since a log that is a directory or
unwritable fails there first. The one that needed no race is the
`TIMEOUT` line, appended between the read loop and the
SIGTERM/SIGKILL/`waitpid` that are the only things that stop a hung
agent: an agent that removed or replaced `.karr.log` during its own
half-hour run took that append down with it and outlived the timeout it
had earned. That append is now best-effort, and its failure is warned
once the child is safely reaped instead of thrown in front of the kill;
the END line still raises it for real if the log is unwritable by then.
Nothing between the fork and the `waitpid` can throw any more.
t/148-foundation-runner-child-leak.t pins both halves, and t/122's #143
assertion that the child gets reaped became the assertion that there is
no child to reap.
on top of the two the distribution configures by name. That one has no
C<target>, so it built the last stage in the F<Dockerfile> â which is
C<runtime-user>, not C<runtime-root> â and no C<tags>, so it inherited
the plugin default C<latest %V %v>: exactly the tags C<runtime-root>
publishes. Which image C<raudssus/karr:latest> ended up carrying
therefore depended on the order the plugins happened to run in. Only
the two named builds run now.
- The bundled agent skill documents C<karr materialize>, C<karr import>
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 #102 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 #117 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 #113 and #114). 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 #89 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 #107). 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 #89 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
#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`,
`edit`, `delete`, `archive`, `handoff`, `pick` and `unlock` (ticket #105,
finishing #38). 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 #77). `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 #27), 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 #38). 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
`karr destroy` on another clone now reaches this one). This guard
catches the total wipe only; the remote swapped for a different,
non-empty board â which leaves refs standing and so slips past it â is
caught by the board identity described above (ticket #95).
- Fixed a failed `karr restore` destroying the board instead of restoring
it. Restore deleted `refs/karr/*` first and wrote the snapshot back
afterwards, so a snapshot karr could not write took the board with it: a
single unusable ref name left the board empty locally, and then on the
remote too, because the push insurance faithfully mirrored the
half-executed destruction. Every ref name is now validated and every
commit object built before the first ref moves, so a snapshot karr cannot
apply is refused with the board untouched, and the refs it can apply are
overwritten in place instead of starting from an empty namespace. The ref
updates themselves are still a loop rather than one transaction â an I/O
failure part-way through can still leave a board holding a mix of old and
new refs â but the board is no longer emptied before the first write, so
no failure can leave it with nothing in it. A snapshot may also no longer
address refs outside `refs/karr/`, which previously let a hand-edited
backup overwrite a branch.
- Fixed any write command silently seeding a partial board in whichever
repository it was run in, and that partial board then locking `karr init`
out of it for good â `karr create` typed in the wrong directory was
enough, and `karr destroy --yes` was the only way back. A board now
counts as existing only when `refs/karr/config` is present; the commands
that write to the board refuse with "No karr board found" when it is
absent, and `karr init` completes a half-board (keeping its ID counter,
so existing tasks are not overwritten) instead of refusing. `backup`,
`destroy`, `materialize` and `repair` still work on whatever is under
`refs/karr/`, so a half-board an older karr left behind can still be
inspected and removed. `karr import --yes` is still allowed to bootstrap
a board from a bare kanban-md `tasks/` view, and now writes the default
config ref when the view has no `config.yml`, so the board it leaves
behind is one the writing commands accept. The read-only commands
(`list`, `board`, `show`, `context`, `log`, `config get`) are unchanged:
they still report an empty board with the default config rather than
refusing.
- Fixed a ref deletion that did not happen reporting success. `delete_ref`
discarded the libgit2 error and always incremented the write counter
SyncGuard reads to decide whether local refs still need pushing, so a
failed or no-op delete both claimed success and left the push insurance
believing there was unpushed work. It now returns false when nothing was
removed, counts only deletes that landed, and retries lock contention
like every other ref write; clearing a whole namespace re-reads it
afterwards, so `karr destroy` can no longer report success over refs that
are still there.
- Fixed every task write path accepting a status, priority, class or due
date that does not exist. `karr move 1 totally-invalid`, `karr create x
--priority bogus`, `karr edit 1 --status bogus` and `karr pick --move
bogus` all exited 0 and wrote the value to the board, which then sat in
no column â invisible on `karr board`, still counted in the total, and
`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
the literal `done`, so a move straight to `done` or `archived` recorded
neither; and `started` was written as a bare date while every other
timestamp carried a time. All four are fixed, and `karr archive` and
`karr handoff` now maintain the stamps as well. Unlike kanban-md, karr
does not re-stamp `completed` when a finished task is archived â the date
it was actually finished is kept.
- Fixed `karr create --body 0` silently dropping the body, and `karr show`
not printing a body of `0`.
- Fixed a body losing one trailing newline on every save. The two storage
paths disagreed about it, so a body ending in blank lines shrank each
time the task was written. Both paths now agree: a stored body never ends
in a newline.
- Fixed `claim_timeout` silently meaning one hour for any compound
duration. Only `1h` and `30m` shapes were understood, so a config
imported from kanban-md with `claim_timeout: 1h30m` meant 90 minutes
there and 60 here. The full Go duration grammar is now parsed (`1h30m`,
`90s`, `2h45m30s`, `0.5h`); `7d` is still rejected, as it is by Go.
- Fixed task filenames being cut mid-word at 50 characters while kanban-md
trims to the last word boundary, which gave the same task two different
filenames in a shared `tasks/` directory.
- Fixed `karr import` accepting a `config.yml` that does not validate â a
`defaults.status` naming a status that does not exist was written to the
board and then applied to every new task. The board config is now
validated wherever karr writes it (`import`, `config set`, `disable`).
`karr restore` is deliberately not covered: it replaces refs verbatim
from a snapshot. `karr config show` also no longer dies with a raw Perl
error on a board whose config is already broken.
- Fixed parallel `karr create` silently losing tasks. Task IDs were handed
out by reading a counter ref and writing it back, so two agents that read
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
compare-and-swap on the OID it was read from; an agent that loses that
swap picks nothing and moves on. Verified with 12 forked contenders
behind a barrier: 12 picks, 12 different tasks, and every agent named on
the card it was told it got.
- Fixed one orphaned lock ref bricking every command on the board.
`list_task_refs` matched `refs/karr/tasks/N/lock` as well as `.../data`,
so a lock left behind by an agent that died mid-pick made its task id
exist after the card was deleted; `load_tasks` mapped that id to undef
and `list`, `board`, `materialize` and `pick` all died on it, with no way
out short of `git update-ref -d`. Only the data ref makes a task exist
now, and the board list never contains undef.
- `karr pick` no longer publishes its lock to the remote or strands its log
entry. The lock was released, and the pick logged, after the push, so the
remote kept the lock ref forever and the activity-log entry never left
the clone. Both now happen before the push.
- Locks expire. An agent that died between acquiring and releasing left a
lock nothing could ever clear, and its task stayed unpickable forever. A
lock older than the new `lock_timeout` board setting (default `5m`) may
be taken over, itself by compare-and-swap against the revision whose age
was judged, so a holder that refreshes in between is never silently
evicted. This is deliberately not `claim_timeout` (default `1h`): a claim
covers a work session, a lock covers one pick.
- New command `karr unlock`: with no arguments it lists the pick locks
currently held, with their holder, age, and whether they have expired;
given task ids or `--all` it breaks them. The manual escape hatch for a
stuck board, and the only one on a board that sets `lock_timeout` to
`0s`. Breaking a lock cannot corrupt a concurrent pick â the claim is
bound by the compare-and-swap on the card, not by the lock.
- Board ref commits carry the time they were written. The git signature was
built once and cached for the life of the process, so every ref a
long-running driver (`karr-foundation`) wrote was stamped with the time
of its first write.
- Fixed ordinary ref contention aborting commands with a raw libgit2 error
("failed to lock file '.../lock.lock' for writing") followed by a stack
trace of module paths and line numbers. Losing the race for a ref's lock
file is now retried with a randomised backoff, and a ref write that
genuinely fails reports a single karr-level line.
- Raised the minimum Git::Native to 0.004 and Git::Libgit2 to 0.005. Those
releases add compare-and-swap reference updates (`expected_old`) and
per-ref outcomes from fetch/push, which karr needs to make ID allocation
and lock acquisition atomic and to notice a server-rejected push. They
also fix git+ssh remotes under libgit2 < 1.7 by verifying the hostkey
against `~/.ssh/known_hosts`.
- Fixed a frontmatter value ending in `---` corrupting the task and
bricking the board. The closing delimiter was not anchored to the start
of a line, so a value that merely ended in `---` â `karr edit 1 --block
"waiting ---"` was enough, and YAML dumps such a value unquoted â cut the
frontmatter mid-line. Every command that loads the board then died with
"Missing required arguments: id, title", `delete` included, so the board
could not be repaired with karr at all. The parser now scans for `---` at
- An unknown subcommand (`karr definitely-not-a-command`) now fails loudly
with "Unknown command: ..." on STDERR and a non-zero exit instead of
silently printing the board summary with exit 0 â a typo like
`karr agent-name` (for `agentname`) used to look like success. Bare
`karr` (with or without options like `--done`) still renders the board.
- `karr archive` with IDs that do not exist now exits non-zero, matching
the die-based behaviour of every other id-taking command (show, move,
edit, delete, handoff already did this). In a comma-separated batch
(`karr archive 5,99`) the existing tasks are still archived and the
missing ones reported â partial success is kept, the exit code reports
the failure (kanban-md parity). Re-archiving an already-archived task
remains a successful no-op.
- `karr board` (and bare `karr`) no longer lists done tasks by default â
on a living board the Done section grows forever and drowns the open
work. The footer instead notes how many were hidden ("10 tasks (5 done
hidden)"), and the new `--done` flag restores the full listing. Applies
to the default, `--tags`, and `--json` renderings (JSON keeps the done
column and its real count but empties its task list unless `--done` is
given); `--compact` still shows every status. This deliberately deviates
from kanban-md, whose board always renders done tasks.
- Fix the `updated` timestamp never being bumped when a task is mutated
through the ref-backed store: move, edit, pick, handoff, and archive all
left `updated` at its previous value, so `karr show` (most recently
updated), `karr show --last N`, and `karr list --sort updated` gave
wrong answers. The bump now happens centrally in the board store
whenever an existing task ref is saved â matching kanban-md, which
stamps `Updated` in every mutating command. Creating a task keeps
`updated` equal to `created`, restore/import paths preserve the
original timestamps verbatim, and materializing the on-disk view no
longer rewrites `updated` to the current time (it copies the ref values
unchanged).
- Fix `karr archive` dying with an opaque Path::Tiny error ("paths require
defined, positive-length parts") on ref-backed tasks â the normal case
since boards moved to `refs/karr/*`. Archive was the only mutating
command still calling `$task->save` (which needs an on-disk `file_path`)
instead of persisting through the board store like move/edit/pick/
handoff do. `Task::save` without a directory argument now croaks with a
clear message when the task has no `file_path`, instead of the
Path::Tiny error.
0.303 2026-06-28 02:07:23Z
- Docker: build Alien::FFI against the system libffi (apt libffi-dev) instead
of fetching a libffi tarball from a GitHub release page, which broke the
image build intermittently in CI (Alien::Build itself warns the
release-page download negotiator "will typically not work"). The runtime
image now ships libffi8 for the dynamically linked FFI::Platypus. The
vendored libgit2 (share) build is unchanged, so the runtime stays
self-contained.
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`);
pre-existing bare-email logs are still read for the `user` role.
- karr show: with no ID shows the single most recently updated task;
`--last N` widens that, `--me` shows the task(s) the current identity
most recently acted on (via the activity log), and `--agent NAME` shows
the task(s) most recently claimed by that agent name.
- karr board: hide the `@claimed_by` badge and claimed-count for tasks in
a terminal status (done/archived) â a claim is an active lease, and the
history remains in the activity log.
- sync: surface the real libgit2 error on a failed pull/push instead of a
meaningless "(exit code $?)" (native libgit2 operations have no shell
exit code). New Git `last_error` accessor records the last remote-op
exception.
0.300 2026-05-27 20:43:23Z
- Docker: bundle libgit2 (Alien::Libgit2 share build) so the runtime
image is self-contained. Builder installs cmake/pkg-config/zlib/
libssh2 dev headers and sets ALIEN_INSTALL_TYPE=share; runtime-base
installs libssl3/libssh2-1/zlib1g (the shared libs the vendored
libgit2.so links against). Needed since Git::Native moved to
Git::Libgit2 (libgit2 FFI).
- Add .github/workflows/ci.yml (perl 5.36/5.38/5.40) using the
[@Author::GETTY] dzil-test composite action; installs libgit2-dev so
Alien::Libgit2 links the system libgit2 (>= 1.5) in CI.
- 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 ...'":
Cmd::Context now `use Time::Piece`, so `gmtime` returns a
Time::Piece object instead of a plain string. Added t/07-context.t
covering the plain, --json, and recently-completed cutoff paths.
- Fix `karr config show` (and get/set) crashing with
"Can't locate object method 'board_dir'": Cmd::Config now builds
its config via `$self->store->effective_config` and persists with
`$self->store->save_config`, instead of calling the non-existent
`board_dir` on itself. Added t/06-config-cmd.t.
- Drop hard-coded `tags = latest` / `tags = user` in the Docker
subsections so the new `[@Author::GETTY::Docker]` default
(`latest %V %v`) applies. `runtime-user` keeps a `-user`
suffix on each tag.
- Add `karr-foundation` binary and `App::karr::Foundation` module:
single-shot daemon for periodic agent execution across multiple karr
boards. Reads `~/.config/karr-foundation/config.yml` (dirs: / scan:),
checks each repo for board changes or open tasks, and invokes the
per-repo `.karr` command. Supports `--force`, `--dry-run`, `--verbose`.
Per-repo state in `.karr.state` / `.karr.lock` / `.karr.log` (gitignored).
0.202 2026-05-17 05:17:07Z
- Fix `karr list` crashing with "Can't locate object method 'load_tasks'":
Cmd::List was missing `with 'App::karr::Role::BoardAccess'` (the role was
`use`d but never consumed). Surfaced while writing worktree tests.
- Add t/29-worktree.t covering init/create/list inside `git worktree`
directories and verifying refs/karr/* are correctly shared between the
main work-tree and additional worktrees.
0.200 2026-05-16 17:45:23Z
- Centralize config knowledge: priority_order(), class_order(),
terminal_statuses(), is_terminal_status(), status_requires_claim()
moved to Config and BoardStore (no more duplication across commands).
- Add all_status_names(), status_requires_claim(), is_terminal_status()
to BoardStore for encapsulated status config access.
- Convert all require Time::Piece to use Time::Piece (Pick, Move, Edit).
- Extract append_log into App::karr::ActivityLog module.
- Architecture refactor: split Role::BoardAccess into Role::BoardDiscovery +
Role::SyncLifecycle. Commands now work directly on refs via BoardStore
instead of via a materialized temp directory.
- Add SyncGuard (push insurance on die/croak), effective_config() on BoardStore,
and $self->config via Role::BoardDiscovery.
- Add tasks/ to .gitignore (never commit materialized view).
- Fix CPAN smoker failures: skip git tests on old git (< 1.8.5, no -C flag)
- Fix skip() without SKIP block in t/11-git-impl.t (Test::More crash)
- Skip user.email test gracefully when not configured
0.101 2026-03-23 03:02:05Z
- Strengthen docs and GitHub landing pages
( run in 1.286 second using v1.01-cache-2.11-cpan-788537b7465 )