NAME

App::karr::Config - Board configuration management

VERSION

version 0.500

SYNOPSIS

my $config = App::karr::Config->new(
  file => path('/tmp/karr-materialized/config.yml'),
);

my @statuses = $config->statuses;

DESCRIPTION

App::karr::Config wraps the board configuration file and centralises access to derived values such as status names, priority order, and merged effective defaults. It is used by command modules that need a structured view of the materialized board config instead of working with raw YAML hashes. In the ref-first architecture the canonical config lives in refs/karr/config, while this class works with the temporary YAML file generated for a command run.

SEE ALSO

karr, App::karr, App::karr::BoardStore, App::karr::Task, App::karr::Git

from_merged

my $config = App::karr::Config->from_merged($effective_hash);

Wraps an already-effective config hash (defaults merged with overrides, typically "effective_config" in App::karr::BoardStore) directly as a App::karr::Config instance, with no file behind it -- ->file answers undef. This is the entry point almost every command uses to get a queryable config object for the current board:

App::karr::Config->from_merged( $self->store->effective_config )

is the standard idiom (see App::karr::Cmd::Handoff, App::karr::Cmd::Pick, App::karr::Cmd::Edit, among others). Because file is unset, "save" on an instance built this way dies dereferencing it -- these instances are for reading and validating, not for writing back; write through "save_config" in App::karr::BoardStore instead.

save

$config->save;

Dumps $self->data as YAML to $self->file. Only meaningful for an instance constructed with a real file (App::karr::Config->new(file => $path)); an instance from "from_merged" has no file and this dies dereferencing undef. This is not how the board config is written in normal operation -- refs/karr/config is written by "save_config" in App::karr::BoardStore, which validates and diffs against defaults first. save writes data verbatim to a YAML file and exists for code that works with the temporary materialized config view directly.

statuses

my @statuses = $config->statuses;

Returns the configured status names in board order, accepting both the mapping form { name => 'in-progress', require_claim => 1 } and a bare string. "classes" follows the same convention over the classes list.

This is the list "validate" checks for a minimum of two entries and no duplicates, and that "validate_status" and status_requires_claim look a single name up against.

status_config

my $sc = $config->status_config('in-progress');
# { name => 'in-progress', require_claim => 1 }

Returns the full configuration entry for one status: the mapping as configured, or a synthesized { name => $name } when the board wrote it as a bare string, or undef when no status by that name exists. Contrast with "statuses", which returns every name and none of the per-status detail.

This is the one place a single status name is resolved to what the board says about it; "status_requires_claim" is a boolean view of the require_claim key of what it returns, and any further per-status option belongs here too rather than in a second walk over statuses (ticket #121). Note that the synthesized entry for a bare string carries nothing but name -- that is what makes a bare status require no claim.

priorities

my @priorities = $config->priorities;

Returns the configured priority names in order, or the built-in low medium high critical when the config carries none. Unlike "statuses" and "classes", entries are always bare strings -- kanban-md's priority list has no per-entry options to carry.

classes

Returns the configured class-of-service names in board order, accepting both the mapping form { name => 'expedite', wip_limit => 1 } and a bare string, the same way "statuses" does.

my @classes = $config->classes;

claim_timeout

my $raw = $config->claim_timeout;   # '1h', unparsed

Returns the board's configured claim-expiry duration as the raw string from the config ('1h' when unset), in kanban-md's time.ParseDuration grammar -- not seconds. Pass it to "parse_duration" to get a number. Governs how long karr pick and the move/edit/handoff claim check (App::karr::Role::ClaimTimeout) honour an existing claimed_by before treating it as expired; distinct from lock_timeout, which bounds a single karr pick transaction rather than a whole work session.

foundation_enabled

Returns true when automated agent runs (App::karr::Foundation) are allowed on this board. The flag lives in the board config under foundation.enabled and therefore travels with refs/karr/config; a board that never set it is enabled.

if ($config->foundation_enabled) {
    # karr-foundation may drain this board
}

foundation_reason

Returns the free-text reason recorded alongside foundation.enabled, or undef when none was given. Only meaningful while the board is disabled.

my $why = $config->foundation_reason;

parse_bool

Coerces a CLI-supplied boolean string to 1 or 0, dying on anything else. Needed because a bare "false" from the command line is true in Perl.

my $bool = App::karr::Config->parse_bool('false');   # 0

parse_duration

Parses a Go time.ParseDuration string into seconds, returning undef when it is not a duration at all. kanban-md writes claim_timeout in that grammar, so a compound value such as 1h30m has to mean ninety minutes on both sides of the interop boundary (ticket #78).

my $secs = App::karr::Config->parse_duration('1h30m');   # 5400
my $secs = App::karr::Config->parse_duration('7d');      # undef -- no day unit

validate_status

Dies unless the value is one of the board's configured statuses, returning the value otherwise so it can be used inline.

$task->status( $config->validate_status($wanted) );

validate_priority

Dies unless the value is one of the board's configured priorities.

validate_class

Dies unless the value is one of the board's configured classes of service.

validate_due

Dies unless the value is a real calendar date in YYYY-MM-DD, the only form kanban-md's date.Date accepts.

App::karr::Config->validate_due('2026-02-30');   # dies

validate

Checks a fully merged board config and dies with a Board config is invalid: message on the first problem, mirroring kanban-md's Config.Validate. Only the parts karr actually models are checked -- karr keeps next_id in a ref rather than in the config, has no WIP limits or TUI section yet, and uses its own version numbering, so those three checks are deliberately absent.

Called from "save_config" in App::karr::BoardStore, which is the single write choke point for refs/karr/config, so karr config set, karr import and karr disable all reject a broken schema instead of writing it (ticket #78). It is not called on the read path: a board that is already broken has to stay loadable, or it could not be repaired with karr itself.

App::karr::Config->validate( $store->load_config );

is_terminal_status

Returns true if the given status is terminal for this board.

if ($config->is_terminal_status($task->status)) {
    # task is in a terminal state
}

Called on the class it answers for the default board, done and archived; call it on an instance to have the board's own statuses decide. See "terminal_statuses" for why that matters (ticket #67).

terminal_statuses

Returns the board's terminal status names, done-equivalent first.

my @terminal = $config->terminal_statuses;   # ('shipped', 'archived')

karr used to hardcode done and archived here, which is right only for the default board. A board imported from kanban-md may name its final column anything at all, and on such a board list did not hide finished work and pick handed it straight back out (ticket #67). The rule is kanban-md's, from Config.IsTerminalStatus: the last configured status is terminal, or the one before it when the last is archived, and archived is terminal regardless.

Note that karr's statuses are not settable from the CLI -- karr config set statuses refuses the key -- so a non-default status list only ever arrives through karr import of a kanban-md config.yml.

handoff_status

Returns the status karr handoff moves a task to on this board.

my $target = $config->handoff_status;   # 'review' on the default board

That is review whenever the board configures such a column -- kanban-md's own target, which it validates rather than derives (cmd/handoff.go:105-110) -- and the last non-terminal status otherwise, so a handoff always lands in the column a card sits in right before it is finished. Called on the class it answers for the default board. Dies as a usage error on a board with no working column at all.

status_requires_claim

if ($config->status_requires_claim('in-progress')) {
    # move/pick into this status must carry --claim
}

Returns true when the named status is configured with require_claim set, false both when it is configured without one and when no status by that name exists at all -- never dies, unlike "validate_status". It is exactly "status_config"'s require_claim key read as a boolean, and asks that method for the entry rather than walking statuses itself (ticket #121).

"status_requires_claim" in App::karr::BoardStore wraps this with "from_merged" into the per-board form that App::karr::Role::TaskMutation and karr move/karr pick actually call to gate a status change.

effective_config

my $ec = App::karr::Config->effective_config($overrides);
my $ec = App::karr::Config->effective_config($overrides, name => 'My Board');

Deep-merges $overrides (a board's sparse refs/karr/config contents, or {}/undef for none) over "default_config", with %args forwarded to it, and returns the merged result as a plain hash reference -- not a blessed App::karr::Config instance. Wrap the result in "from_merged" to get one.

Not to be confused with "effective_config" in App::karr::BoardStore, the per-store cached wrapper most command code actually calls: that instance method calls this class method once (via "load_config" in App::karr::BoardStore) and caches the hash it returns.

default_config

my $defaults = App::karr::Config->default_config;
my $defaults = App::karr::Config->default_config( name => 'My Board' );

Returns the hash reference of built-in defaults a board starts from: the status/priority/class-of-service lists, claim_timeout and lock_timeout, foundation.enabled, and the defaults.status / defaults.priority / defaults.class triple new tasks are created with. name is the only override taken, for karr init --name; everything else is the fixed starting point.

effective_config layers a board's sparse refs/karr/config overrides on top of this to answer what the board actually uses, and App::karr::BoardStore diffs against it the other way -- save_config only ever writes the keys that differ from these defaults, so a board that never touched lock_timeout does not carry a frozen copy of it forever. BoardStore also calls it directly to seed a fresh board on karr init and, on karr import of a kanban-md tree with no config.yml, to bootstrap one that is not half a board (ticket #30).

It also stands in for a real board wherever a class-level call has none to derive from -- "handoff_status" is one such caller -- the same class-vs-instance convention "is_terminal_status" documents.

file_view_config

Returns the effective config reshaped for the materialized kanban-md file view: boolean-typed keys become real YAML booleans instead of Perl's 1/0, and next_id is filled in from the next_id argument. Both are load-bearing -- go-yaml refuses to unmarshal 1 into a bool and kanban-md rejects a config whose next_id is below 1, so without either the whole board is unreadable to kanban-md (ticket #60). The caller has to dump it under local $YAML::XS::Boolean = 'JSON::PP' for the booleans to survive.

my $view = App::karr::Config->file_view_config( $effective, next_id => 7 );

reconcile_view_config

Folds a file view's config.yml into the board's existing sparse overrides and returns the reconciled overrides. The view wins for every key it carries and karr models; every other key keeps whatever refs/karr/config already said.

Import used to replace the board config with the file view outright, which only works if the view can express everything the board holds -- and it cannot. kanban-md rewrites config.yml the moment it loads one, migrating it to its own schema version and re-serializing it from its Go structs, so every key that schema does not know is simply gone: karr's foundation and lock_timeout among them. A board turned off with karr disable came back on from an ordinary karr materialize / kanban list / karr import --yes round trip (ticket #87). Reading the view as "here is what changed" rather than "here is the whole config" is what makes a key kanban-md never heard of survive it.

The same reconciliation is what keeps the migration itself out of the board config: the view's keys are pruned to what karr models and normalized to the shape "default_config" uses, so kanban-md's migrated defaults compare equal to karr's and are not recorded as deliberate per-board overrides (ticket #88).

my $overrides = App::karr::Config->reconcile_view_config(
  $store->load_config_overrides, $file_config );

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/karr/issues.

IRC

Join #langertha on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.