-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathChanges
More file actions
2189 lines (2131 loc) · 151 KB
/
Copy pathChanges
File metadata and controls
2189 lines (2131 loc) · 151 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
{{$NEXT}}
0.500 2026-08-13 23:57:25Z
- Three karr-foundation fixes (#165, #166, #168). `max_runtime: 0`
no longer silently turns `drain: true` into a single run: the
drain's wall-clock guard now reads `&& $max_runtime > 0`, so 0
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
already used this convention; the classifier now matches it for
every signal, including the ones we don't fire ourselves.
- `karr pick` ranks candidates and `karr context --sections in-progress`
lists in-progress tasks by the board's own `priorities` and `classes`
lists, not by a hardcoded table (ticket #149). `Cmd/Pick.pm` used to
sort through `App::karr::Config->priority_order` and `->class_order` —
class methods that only knew the four default priorities and four
default classes. On a board imported from kanban-md with a longer
priorities list (or any class name the table did not recognise),
every unknown priority collapsed into the `// 2` fallback, the sort
became a no-op on that axis, and the wrong card went out — silently
and consistently, while `karr list --sort priority` showed the right
order right next to it. The reproduction in the ticket was a board
with priorities `[low, medium, high, critical, blocker]`: `karr pick`
handed out the merely-critical card because `blocker` was unknown to
the table, and `karr context --sections in-progress` listed
`critical` above `blocker` for the same reason. The fix reads
`$self->config->priorities` and `$self->config->classes` and sorts
by class index (lower = more urgent) then by priority index (higher
= most urgent), matching kanban-md's `internal/board/pick.go:74-90`.
`App::karr::Config->priority_order` and `->class_order` are removed
rather than left in place: their only callers were the two bug sites,
and leaving them around invites a future caller to use the wrong one
again. The `=attr priority` / `=attr class` POD in `Task.pm` now
references the `Config/priorities` and `Config/classes` instance
methods.
- `karr edit --status X --release` no longer walks straight through the
`require_claim` guard and lands a card in a require_claim column with
no claim on it (ticket #150). `Cmd/Edit.pm` cleared the claim forty
lines after `apply_status_change` had already satisfied the guard with
that same claim, so `karr edit 1 --claim agent-a` followed by
`karr edit 1 --status in-progress --release` produced an unowned
in-progress card — a state `karr move` and `karr edit --status` both
refuse to create by any other route. `karr edit 2 --status in-progress
--claim agent-b --release` did it in one command. The fix has two
pieces: `--claim` and `--release` are rejected together at the flag
layer as a usage error (exit 2), matching kanban-md
(`cmd/edit.go:128-130`); and the `--release` block now runs before
`apply_status_change`, so the guard in
`Role::TaskMutation::apply_status_change` sees the post-release state
and refuses the status change, matching kanban-md's `validateEditPost`
firing after `applyFn` regardless of release
(`internal/board/mutate.go:442`). `--release` alone on a card already
in a require_claim column is intentionally left unchanged: it is the
same shape but outside the ticket's scope (kanban-md has the same
hole).
- 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.
- The lookup for the bundled skill file has one implementation instead of
two (ticket #146). `App::karr::Cmd::Init::_find_skill_source` and
`App::karr::Cmd::Skill::_skill_content` were the same sub twice over —
byte-identical apart from the `$INC` key each read to find its own source
tree for the development fallback — which is the shape that made ticket
#142 fix the skill *write* in one command and left #145 to fix it again
in the other three commits later. Both are now `_skill_content` on
`App::karr::Role::SkillFile`, next to the `_write_skill` #145 collapsed
the same way, and the role still requires nothing of its consumer, which
is what lets it serve board-less `karr skill` and board-composing
`karr init` alike. The fallback anchors on the role's own loaded path
rather than on a command's: naming either command's file would answer for
one caller and send the other silently on to "Could not find
claude-skill.md", and since MooX::Cmd decides which command classes get
loaded, whether that happened would depend on how karr was invoked. No
change in behaviour: both lookups, both fallback triggers and both
commands are exercised end to end in t/26-skill-share-dir.t, including
through the CLI with File::ShareDir made to fail the way an uninstalled
dist makes it fail.
- `karr metrics` no longer averages impossible cycle times (ticket #140).
A completed card whose `completed` precedes its `started` measures a
negative duration, which is not a cycle time; it is now left out of that
average and counted in `unusable_timestamps`, the same way a `started`
that precedes its own card's `created` already was. The start check had
quietly stopped catching these: #138's `karr repair` clamp raises
`started` to `created`, so the old condition holds by construction on
every card it touched, and the pre-#68 bare-date `completed` — midnight
of its day — then falls below the new start. On karr's own board that
was 42 of 117 cycle samples negative, an average cycle time of 16
minutes, and `unusable_timestamps` reporting 0 — the figure whose whole
job is to say what is missing from the averages, saying nothing.
`unusable_timestamps` is now documented as what it has always counted:
cards, not stamps, each one missing from at least one of the two
averages. Lead time is deliberately untouched here and can still be
negative where `completed` precedes `created` — that was ticket #139's
decision to take, and it is the entry below. `completed` equal to
`started` stays measurable — a
card moved straight into a terminal status has a cycle time of zero, and
zero is a measurement.
- `karr metrics` now says how much of its lead average cannot be believed
(ticket #139). A card whose `completed` precedes its own `created`
contributes a negative lead time, and it still does: unlike `started`,
which karr manufactured and `karr repair` rewrites, `created` and
`completed` are original data, so a negative lead time is evidence of a
bad completion rather than something to normalise away — and every value
a clamp could pick would be an invention (`created` is too early,
`started` asserts a zero cycle time, and the end of the day a bare date
bounds was never written down). So the sample stays in the average and
is qualified instead of cleaned: `negative_lead_samples` under `--json`,
always present including as 0, and a closing note in the default and
compact renderings stating the same figure. Deliberately *not* folded
into `unusable_timestamps`, which counts cards missing from at least one
average — these are missing from nothing; both definitions are now
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 #68 (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 #142). 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 #145). It installs the very file `karr skill install --agent
claude-code` installs, and it still did so with the `spew_utf8` that
ticket #142 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 #141): `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 #128 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 #144): `store`, from `Role::BoardDiscovery`. It
declared nothing at all and composed cleanly into anything, which is the
state #128 found `Role::DependencyCheck` in and #141 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 #138): it raises a
`started` stamp that precedes its own card's `created` up to that
`created`. karr wrote `started` as a bare date until #68, 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-#68 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 #137). 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 #128 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 #126), the last kanban-md feature
karr did not have: throughput over fixed 7- and 30-day windows, average
lead time (created to completed) and cycle time (started to completed),
flow efficiency, and aging work items — started, not terminal, no
completion — oldest first. `--since`, `--json` and `--compact` as
elsewhere; archived tasks are excluded the way `list` and `board`
exclude them, and like the other read commands it does not sync first
and refuses a repository holding no board rather than reporting zeroes.
Every figure comes from the lifecycle stamps on the cards and from
nothing else. The activity log was considered and rejected as a source:
its entries record the status a write left a task in, not the
transition, it only exists since #64, and `import`/`restore`/`repair`
write refs without logging — so a log-derived cycle time would come out
silently short on exactly the oldest boards and disagree with the cards.
Two departures from kanban-md, both deliberate: flow efficiency is the
summed cycle time over the summed lead time of the same cards, not one
average divided by another over different populations, which is how
kanban-md can report over 100%; and the JSON carries `lead_samples`,
`cycle_samples` and `unusable_timestamps` (the text render carries the
counts too), so a figure resting on two cards cannot pass for one
resting on two hundred. Cards whose stamps cannot carry a measurement —
an unreadable date, or a `started` that precedes the card's own
`created` — are left out of the averages that need them and named in a
note.
- `karr unlock` no longer announces a lock it did not break (ticket #119).
`Git::delete_ref` returned `0` both for "the ref was never there" and
for "libgit2 refused the delete", so a caller could not tell them apart
— and `Lock::break_lock` read the second as the first: with the delete
refused, `karr unlock` printed "Broke lock on task N" and exited 0 while
the lock ref was still on disk and the card still held, with nobody left
to look at it. `delete_ref` now raises on a refusal, the way its
compare-and-swap twin and every other ref mutation in the class already
did: `1` means this call removed it, `0` means there was nothing to
remove, and a refusal is an exception carrying libgit2's reason. Lock
contention is unchanged — still the retry path, not a failure — and so
is the `0` for a repository that cannot be opened at all, since that is
the global-destruction teardown from #63 rather than a refusal. The
delete itself stays unguarded: `karr destroy`, `delete_refs` and
`break_lock` still remove whatever is there. `karr destroy` now reports
*why* it is stuck rather than just which refs are left, and attempts
every ref before raising instead of stopping at the first refusal;
`restore`'s best-effort cleanup catches the refusal, and no longer
mislabels a ref that vanished underneath it as stuck.
- `karr config get KEY --json` now always wraps the answer in the key that
was asked for (ticket #131). It wrapped scalars and printed lists and
mappings bare, so `karr config get board --json` answered
`{"name":"..."}` — byte-identical to the wrapped form of a scalar key
called `name`, with nothing in the payload to tell the two apart, and a
consumer reading it as `{"board": ...}` silently got the wrong shape.
Mappings and lists now carry their key too, as `board` and `statuses`;
scalars are unchanged. That also makes `get KEY --json` a one-key subset
of the `config show --json` object rather than a second schema. This is
a deliberate break of a machine-readable interface, not a bug fix:
consumers that read the bare list or mapping have to index the requested
key first. Nothing in the distribution, the shipped skill or
karr-foundation read the old form. Two asymmetries are left alone as
separate decisions: `config set --json` answers with its own
`{"key":...,"value":...}` shape, and a dotted key still wraps flat
(`get board.name --json` → `{"board.name":"..."}`, not a nested object),
so a dotted `get` is still not a subset of `show`.
- Deleting a task id that was never there no longer writes an activity-log
entry claiming it was deleted (ticket #120). `Role::BoardAccess`'s two
write doors disagreed by accident: `save_task` guarded its
`log_task_write` on the store write having succeeded, `delete_task`
called it unconditionally. Since #64 that log feeds `karr log` and `karr
show --me`, so `karr delete 999` on a board with no task 999 reported a
delete that never happened — and an entry has no room to say
"attempted", since it carries only agent, action, task id and detail.
The third write path, `Role::TaskMutation::delete_task_guarded`, already
dies on a missing id before it logs, so the unguarded door was the lone
outlier of three.
- `Role::BoardAccess::save_config` is gone (ticket #120). Nothing in the
distribution called it — every config write goes to
`$self->store->save_config($hash)` directly — and its no-argument form
would have corrupted the board: it defaulted to `$self->config`, an
`App::karr::Config` object, where `BoardStore::save_config` expects the
plain effective-config hash. Handed the object it diffed the blessed
hash's own `data` and `file` keys against the defaults, and because that
merges into something schema-valid, `Config->validate` — the single
validation gate — waved it through: the result was a `refs/karr/config`
with the board's entire real config nested under a `data:` key and
`board.name` gone. It has been there since the initial release, so this
is an API removal against 0.402 for anyone who composed the role outside
this distribution; the role's POD now records
why the door is deliberately absent, since `save_task` and `delete_task`
earn theirs by writing the activity log and a bare pass-through does not.
- The COMMANDS block of `karr --help` lines its descriptions up in one
column again. It never did: the row was rendered with `sprintf " %-*s
%s\n", $max, colored($name, 'cyan'), $desc`, and `%-*s` pads to the
length of the string it is handed — which for a coloured name includes
the ANSI escapes `colored()` wrapped around it. Those are about nine
characters on their own, so every argument was already wider than `$max`
and the field never padded at all, leaving each description one space
behind its command name. The padding is now computed from the bare name
and applied before the colour, so `materialize` and `init` put their
descriptions on the same column. The width still derives from the
longest name in the table rather than a fixed number, so adding a longer
command does not silently break it again. What kept this from being
caught: `colored()` returns its text untouched under `NO_COLOR` /
`ANSI_COLORS_DISABLED`, so the existing help tests — which strip escapes
or run plain — saw perfectly aligned output. The regression test renders
the help twice, once forced plain and once with colour forced on, and
asserts the coloured rendering really does carry escapes before
measuring it.
- `karr config get` / `karr config show` answer for this board, or refuse
(ticket #136). They were the sibling of #135 that fix deliberately left
out: `Cmd::Config` called `sync_before` and `require_board` only in its
`set` branch, so the read path did neither and fell back to the code
defaults — documented as a choice, on the grounds that those values really
are the ones that would apply. For `board.name` there is no such reading.
In a fresh clone of a board named "Echtes Board", `karr config get
board.name` printed `Kanban Board`, karr's placeholder, at exit 0, and
left the clone holding zero karr refs; `karr sync` then fetched six and
the same command answered correctly. Both `show` and `get` now go through
`require_local_board` like every other read: still offline, refusing with
exit 1 where nothing is stored under `refs/karr/`, reading a half-board
with the note on STDERR. The defaults keep their honest use — "what would
a board created here start with?" is a real question — but have to be
asked for: `karr config show --defaults` (and `get KEY --defaults`) reads
no board, needs no repository, and answers the same everywhere, so the
difference between the board's value and karr's is carried by the exit
code rather than by an unmarked payload. Which key it is does not decide
anything: in a fresh clone every key can be overridden on the remote, and
`board.name` is only the one that almost always is. `--defaults` renders
identically to a board read, so `diff <(karr config show) <(karr config
show --defaults)` is exactly the set of keys a board overrides. It is
rejected on `set`, with exit 2.
- The read-only commands no longer render an empty board where there is no
board (ticket #135). `board`, `list`, `show`, `log`, `context` and the
bare `karr` summary never asked whether a board was there; they rendered
the code defaults over an empty task list, so a repository holding no
board printed exactly what a board holding no cards prints — down to the
byte, once the board is called "Kanban Board". `git clone` does not fetch
`refs/karr/*`, which makes that the normal state of every fresh clone,
where the tickets are all still on the remote: a user who trusts `0 tasks`
there concludes they are gone. The reads stay offline — a pull in front of
every `karr show` is not worth it, and a stale read is recoverable where a
stale write is not — but they now report what they actually read. Nothing
under `refs/karr/` is refused with exit 1 and a message that names the
namespace, denies the empty-board reading, and, where the repository has a
remote, leads with `karr sync` rather than `karr init`: the board is
unfetched rather than absent, and `init` would answer that by starting a
second, empty one. A half-board (#133) is read rather than refused — its
tasks are demonstrably there — with the note that `refs/karr/config` is
missing on STDERR, so `--json` stays parsable. An initialized board with
no tasks answers exactly as before, which is the distinction that was
missing. `--json` consumers tell the two apart the way they tell every
other karr failure apart: exit 1 and an empty stdout, rather than a
payload full of zeros. The agent skill says so too, in its Sync section:
a fresh clone has no `refs/karr/*`, the read commands refuse until
`karr sync` has run, and `karr init` is the wrong answer there. That is
the loop this came out of — an agent read an empty board, believed it,
and reached for `init`.
- The runtime images can reach an `ssh://` remote (ticket #134). They
shipped without an ssh binary — `runtime-base` installed `git gosu passwd`
and the shared libraries, and nothing else — so git's CLI fallback died
with `error: cannot run ssh: No such file or directory` and a board on an
ssh remote was unreachable from the published images. That fallback is not
decoration: it is there for the ssh-config and `ProxyCommand` setups
libgit2 cannot do, and it could never take a single one of them.
`openssh-client` is now installed with the rest. The other half was the
README's recommended alias, which mounted `.gitconfig`, `.claude`,
`.codex` and `.cursor` but not `.ssh`, while setting `HOME=/home/karr` —
so libgit2 looked for `known_hosts` in a directory that did not exist and
reported every host as unknown, and the fix it printed
(`ssh-keyscan … >> ~/.ssh/known_hosts`) was carried out on the host, where
the container never saw it. The alias now mounts `~/.ssh` read-only, and
an agent-forwarding variant is documented as a shell function, since
`docker run` rejects the socket mount outright when no agent is running.
Neither half helps alone: an ssh binary with no keys cannot authenticate,
and mounted keys with no ssh binary cannot fall back. A third piece only
turned up when the finished image was pointed at a real ssh remote: the
root image drops to whoever owns F</work>, and that host uid has no
C</etc/passwd> entry, so C<ssh> — which looks itself up with
C<getpwuid()> — refused to start with `No user exists for uid 1000`. The
entrypoint now writes an entry for the uid before dropping to it. The
fixed-user image never had this problem, since C<useradd> wrote one at
build time; the default image, the one the README recommends, had it for
every ssh remote.
- `karr init` no longer stamps the encoding marker on a board it is
completing rather than creating (ticket #132). `init` accepts a
half-board — task refs present, `refs/karr/config` missing — and finishes
it (#62), but it used to write `refs/karr/meta/encoding` on the way out
either way. On a board from 0.402 or earlier that marker asserts the
opposite of what the adopted task refs carry: the read path stopped
undoing their double-encoded UTF-8, every old card turned to mojibake
(`… Transport prüfen —` became `… Transport prüfen â`), and `karr
repair` then reported the board as already up to date and declined to
migrate it, leaving hand-deleting a ref as the only way back. Not one
byte in the refs changes in that failure, which is why it was invisible.
The marker is now written only by an `init` that found nothing at all
under `refs/karr/`, so a completed half-board keeps both the repair on
read and `karr repair --yes`. `karr import --yes` had the same defect and
the same fix: it rewrites the task refs from the file view but never the
activity log under `refs/karr/log/` (nor the config, when the view
carries no `config.yml`), so on a pre-0.403 board it stamped a claim it
could not make and turned every old log entry into mojibake. It now
stamps only a board that import itself created. `karr repair --yes` is
unchanged and remains the one command that may stamp an existing board,
because it is the one that rewrites every ref.
- "No karr board found. Run 'karr init' to create one." no longer speaks
for two different repositories (ticket #133). Write commands raised it
whenever `refs/karr/config` was absent, including on a repository whose
tasks, counter and log were all there — so a board missing exactly one
ref reported itself as never having existed. An agent hit that on a
repository holding 21 tickets, believed them gone, and ran `karr init`,
which at the time also broke how they were read (#132 above). A
repository with nothing under `refs/karr/` still gets the old sentence,
the one `backup`, `destroy`, `materialize` and `repair` raise for the
same state. A half-board now gets its own: it is named as one, the number
of task refs at stake is stated, and `karr init` is described as
completing the board while keeping what is already there. `karr init`
likewise reports which of the two things it did.
- `karr config get statuses` and `karr config get classes` are readable
again (ticket #130). Both lists allow an entry to be either a bare name or
a mapping — `{ name: in-progress, require_claim: true }`,
`{ name: expedite, wip_limit: 1 }` — and the renderer joined the raw list,
so every mapping printed as `HASH(0x558580cd1688)`. The default board hits
it twice in `statuses` and, because all four of its classes are mappings,
four times out of four in `classes`, where the answer carried no name at
all. An entry now renders as its name followed by its per-entry settings
in parentheses — `in-progress (require_claim: 1)`, `expedite
(bypass_column_wip: 1, wip_limit: 1)` — and `karr config show` renders the
two lists the same way instead of dropping the settings, so the overview
and the single-key lookup cannot disagree about what the board's columns
are. `--json` is unchanged: it already carried the entries as configured,
and still does. This was not cosmetic — that output is what a reader
consults to learn which columns a board has, and the unreadable entries
led an agent to describe an "extended status set" with a `closed` status
that no board in the tree configures.
- The `runtime-user` image build no longer warns about `/home/karr`. The
shared `runtime-base` stage used to create that directory, so
`runtime-user`'s `useradd -m` was asking for a home that already existed
and printed `useradd: warning: the home directory /home/karr already
exists.` plus `Not copying any file from skel directory into it.` on every
build. `runtime-base` now creates only `/work` and leaves the home to
whoever owns it — `useradd -m` in `runtime-user`, and in `runtime-root` the
entrypoint, which has to `mkdir` it anyway for the uid it drops to, so
nothing there depended on the base stage doing it. Two visible
consequences for the `-user` image: the home is now `0700` rather than
`0755`, because `useradd` applies Debian's `HOME_MODE` instead of
inheriting a `mkdir` default, and it carries the `/etc/skel` files
(`.bashrc`, `.profile`, `.bash_logout`) that the warning had been skipping.
Neither affects the image as documented — it runs fixed as `USER karr`,
which owns that directory, and the README's `-v …:/home/karr/…` mounts are
unaffected. Overriding the uid at `docker run --user` time is the one thing
that gets stricter; that case is what the root image and its entrypoint are
for.
- The README's recipe for a custom fixed-user image passed `.` from a git
checkout as the Docker build context, which cannot work: the `builder`
stage installs the tree with `cpanm`, and a Dist::Zilla distribution has no
`Makefile.PL` until it has been built. It now points at a built
distribution — an unpacked CPAN tarball, or `dzil build --no-tgz` output —
and mentions that `dzil build` already produces both published images
itself through the `[@Author::GETTY::Docker]` sections.
- `karr list --json` now carries the task body (ticket #129). It built its
payload from `Task->to_frontmatter`, the YAML frontmatter view — and the
body lives *below* the frontmatter in the file format, never inside it,
so every card came out of `list --json` without its text while `show`,
`pick` and `handoff`, which go through `Task->to_json_hash`, shipped the
same card whole. Nothing documented the difference: `--json` was
described as "machine-readable" without a caveat, and `to_json_hash`
listed its users without noticing `list` was missing from them. kanban-md
marshals the full task structs in `cmd/list.go`, with
`json:"body,omitempty"` on `Body`, so this was also a parity gap. In
practice it forced anyone reading ticket text by machine into one `show`
per id — N+1 calls for what one call can answer. An absent body stays an
absent key rather than an empty string, as kanban-md's `omitempty`
spells it, and a body of `"0"` counts as text (the #78 rule: emptiness is
length, not truth). Boards with long bodies will see the payload grow;
that is what `--compact` is for. `karr board --json` is deliberately
unchanged: its columns stay a card index, not a text dump — kanban-md's
own `board --json` carries counts and no task payloads at all, so karr
already gives more there than parity asks for.
- `depends_on` can now be set from the CLI (ticket #124): `karr create
--depends-on 2,3` takes comma-separated ids the way `--tags` does, and
`karr edit --add-depends-on` / `--remove-depends-on` follow the
`--add-tag`/`--remove-tag` rule — add appends without duplicating,
remove is a no-op for ids the card does not carry. Until now the field
was reachable only through `karr import` of a file view, so after #123
karr warned about a relationship it could not itself express. The ids
are stored and emitted as numbers, matching kanban-md's IntSlice, so
they round-trip the frontmatter and `--json` numerically. Setting also
validates, as kanban-md does (ValidateDependencyIDs): a non-numeric id
or one the board does not have condemns the whole invocation as a usage
error (exit 2) before anything is written — on create, before an id is
allocated, so a rejected create burns none (the #54 rule) — while a
self-reference is per-id (`karr edit 4,5 --add-depends-on 5` is valid
for 4 and wrong for 5), failing that id and letting the rest of the
batch proceed (the #61 rule, exit 1). Removing an id the board no
longer has stays legal: it is how a dependency on a deleted task is
cleaned up. The #123 move-time warning is unchanged and complementary —
set-time catches a typo while the author still remembers what they
meant, move-time catches state that changed afterwards.
- A scalar where a list field belongs — `tags: urgent`, `depends_on: 1`,
writable only by hand or by a third tool in a materialized view — is now
refused at the parse gate as a usage error naming the field, and through
`karr import` also the file (ticket #125). It used to pass parsing and
die mid-write at the dereference in to_frontmatter, as a raw Perl error
with a source location (the #77 class); on the import path that fired
after refs had already started moving, so the import stopped half done —
config saved, earlier cards rewritten, the bad card and the prune never
reached — despite serialize_from's all-or-nothing promise (#70). Refusing
at parse time puts it behind that gate, and no ref moves. An empty or
null value is not refused: per the #98 interop rule "present but empty"
is the same state as "absent" and now loads as the empty list, where it
previously died at the same dereference.
- karr now reads `depends_on` (ticket #123). The field had been stored,
round-tripped and written into the frontmatter since the beginning and
evaluated by nothing, which is worse than a missing feature: a card
recording `depends_on: [5]` looked as though karr would hold it back
until 5 was finished, because the field was accepted, kept and
materialized — and `karr pick` handed it out regardless. Taking a card
up now warns when a dependency is unfinished, and warns in different
words when one names an id the board does not have. That covers a move
into a non-terminal status (`move`, `edit --status`, `handoff`) and
every `pick`, with or without `--move`: on a pick the claim itself is
the taking-up, so a bare `karr pick --claim X` — the commonest call
there is — warns too. It does not block: the command proceeds and
still exits 0. "Finished" is decided by the board's own terminal
statuses, so a board ending in `shipped` is judged by that column and
not by the literal `done`. The warning goes to STDERR, so STDOUT stays
parseable; under `--json` it rides in the result object instead, where
a JSON consumer can actually see it; `--quiet` silences the STDERR
copy. kanban-md skips such a card in `pick` entirely
(internal/board/pick.go) and counts an unknown id as satisfied
(internal/board/filter.go) — karr deliberately does neither, and says
so at both sites.
- `karr show` now displays `depends_on`, with the current status of each
dependency and `(unknown)` for an id the board does not have (also
ticket #123). The field was previously invisible in every command, which
was half of what made it a trap.
- Every public method in the distribution now carries POD (ticket #118).
The gap was not evenly spread and not a policy: App::karr::ActivityLog,
Error, SyncGuard and four roles documented every method, while
App::karr::Git documented four of fifty-two and App::karr::Lock none of
twelve — although all of them are set up identically, with an ABSTRACT,
a DESCRIPTION and a SYNOPSIS each, so a reader had no way to tell which
modules were meant to be used. Ticket #115, the dead L</last_error>
link, was one symptom of it. The new blocks state contracts rather than
restating method names: what a lookup answers when the thing is missing,
which arguments are required, and which failures are returned versus
thrown. Writing them down surfaced four defects that reading the code
had not (tickets #119 to #122), the sharpest being that
App::karr::Git::delete_ref returns the same 0 for "the ref never
existed" and "libgit2 refused the delete". Documentation only; no
behaviour changed anywhere.
- The Docker images no longer fight over their tags (ticket #116).
F<dist.ini> set C<docker_image> without C<docker_default = 0>, so the
C<[@Author::GETTY]> bundle added a third, unnamed C<Docker::API> plugin
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
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 #105 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
reset to the default by the same route and is preserved likewise.
- Fixed that same round trip recording kanban-md's migrated defaults as
deliberate per-board overrides. Its rewritten `config.yml` carries
`version: 10`, a fully expanded `statuses` list decorated with the
`show_duration` flag karr does not model, and a whole `tui` block —
diffed against karr's defaults, all of it looked changed, so the board
froze a copy of another tool's defaults and stopped following karr's own.
The view's keys are now pruned to what karr models and normalised to the
shape karr writes them in, so a migrated config compares equal to the
defaults and is not stored as an override. Two consequences worth
knowing: a kanban-md `tui` or `wip_limits` block is no longer carried
across the bridge, since it is not karr board config and `karr config`
can neither show nor set it; and a board already polluted by an earlier
import keeps its stale `version` until something rewrites the config.
- Fixed `karr import` walking the task id counter backwards. `karr
materialize` writes `next_id` into the file view because kanban-md
requires it, but import threw that copy away and re-seeded purely from
the highest card it could see. A kanban-md board that has ever lost a
card carries a counter ahead of its highest id and holds it there on
purpose — max(stored, highest id + 1) is kanban-md's own rule for the
same value — so importing one retired ids it had already handed out, and