App-karr

 view release on metacpan or  search on metacpan

.claude/agents/karr-foundation-worker.md  view on Meta::CPAN


You are the **karr-foundation** worker inside App::karr. You own the coordinator that discovers
many boards and decides whether, when, and how an agent is run. Apply the loaded conventions
silently.

## Territory

- `bin/karr-foundation`
- `lib/App/karr/Foundation.pm` and `lib/App/karr/Foundation/*.pm`
- `Cmd/Disable.pm` and `Cmd/Enable.pm`
- foundation configuration, overview output, repo discovery, command/prompt resolution,
  drain budgets, timeouts, local lock/state files, cooldown, stall detection, and auto-blocking
- foundation-specific tests (`t/30-*` through the related foundation/disable coverage)

Preserve the two operating modes: a human can request a read-only overview, while automated
agent execution is opt-in. A board-level disable is synchronized board state and wins before
command resolution and drain decisions. `.karr.lock` and `.karr.state` remain machine-local;
do not confuse them with refs-backed task claims or board configuration.

## Boundaries

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

where that is per fleet. A board naming an agent the config does not define is
an error that skips **that board**, not one that silently stops running. With
no agent on any board, `karr-foundation` prints a read-only overview instead of
running anything (see "Overview").

```yaml
claude: true              # synthesize the canonical claude command (opt-in)
claude_bin: claude        # binary for claude: true (default: claude)
claude_max_turns: 30      # --max-turns for claude: true (default: 30)
claude_permission_mode: bypassPermissions   # (default: bypassPermissions)
prompt: >-                # agent instruction, exposed to the command as $PROMPT
  Use the karr-coordinator skill: pick the next actionable task and move it.
# command: claude -p "$PROMPT"   # explicit command; wins over claude: true
# agent: minimax          # a named agent from the config's 'agents:' section
on_idle: skip             # 'skip' (default) | 'always-run'
mode: drain               # drain (default) | single | ticket
drain: true               # older spelling of mode: true=drain, false=single
max_runtime: 1800         # seconds: per-run TERM, then KILL 2s later (0 = off)
max_attempts: 2           # stalls on one task before auto-block (default: 2)
max_iterations: 50        # hard cap on drain iterations / drain budget (default: 50)
cooldown_base: 1          # cooldown minutes at level 0 (default: 1)
cooldown_max: 64          # cooldown ceiling in minutes (default: 64)
error_patterns:           # extra case-insensitive substrings → common-error
  - my custom api error
on_drained: ./release-gate.sh   # run when the board has no work left
on_drained_max_runtime: 1800    # seconds for that command (0 = no limit)
on_drained_max_rounds: 3        # see "The domain hook" (0 = no cap)
```

`claude`, `claude_bin`, the other `claude_*` knobs, `mode` and the three
`on_drained*` keys may also be set in `config.yml` under the same name;
`command`, `prompt` and `agent` have config-wide spellings of their own
(`default_command`, `default_prompt`, `default_agent`). The per-repo `.karr`
value wins in every case — including `on_drained: ""`, which is how one board
opts out of a fleet-wide hook.

## Named agents

A board has one command; a fleet has several agent commands with different
strengths and different failure modes. `config.yml` names them, a `.karr` picks
one with `agent:`:

```yaml

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


## Environment

During agent execution foundation sets:

- `KARR_REPO` — the repo path
- `KARR_ROLE` — the identity nested `karr` calls write under: `agent` for an
  agent run (`refs/karr/log/agent/<email>`), `hook` for `on_drained`, `chain`
  for a `kind: shell` chain step, `coordinator` for the coordination agent; a
  human defaults to `user`
- `PROMPT` — the resolved agent instruction (`prompt` / `default_prompt` /
  built-in default), referenced as `$PROMPT` in the command template; in ticket
  mode it ends with the sentence naming the assigned task, for a hook it is
  empty, and for the coordination agent it is that agent's own instruction
  rather than the board's
- `KARR_TASK` — the id of the task a `mode: ticket` run was given, empty in
  every other mode

## Cron example

```bash

Changes  view on Meta::CPAN


    - karr's output streams read in the order it printed them, and dialogue
      goes to STDERR (k241, k248, k249, k253). The `:encoding(UTF-8)` layer
      buffers STDERR, so both handles flushed at exit STDOUT-first and every
      warning landed under every result on `2>&1` or a pipe; `enable_std_utf8`
      now autoflushes both, so a combined stream reads in print order and
      `karr-foundation`'s teed run log fills as the run happens (k249). The
      `karr delete` confirmation is unbuffered before the read (k241) and
      goes to STDERR unconditionally (k248) -- a question is dialogue, not a
      result, so `printf "n\n" | karr delete 1 --json` no longer puts a bare
      prompt in front of an object that then will not decode; the outcome
      stays on STDOUT. And `karr log` dropped its own dead no-repository
      branch, which printed plain text ahead of the `--json` check but could
      never run behind `git_root` and `require_local_board` anyway (k253).

    - `karr delete` names what points at the card before it goes (k236,
      k242, k250). Dependents are searched backwards -- a `depends_on` entry
      or `parent` set to the id -- and each is reported on STDERR before the
      confirmation so it can change the answer, offering `karr archive ID`
      to keep the card; the cross-board far card is named too, out of the
      card's own `escalated-from:`/`needs:` tag, under a

Changes  view on Meta::CPAN


    - The fleet plans, asks and routes (k191, k200, k210, k213). A question
      mailbox under `refs/karr-foundation/questions/*` (`ask`/`answer`,
      `--policy block|use_default|escalate_to_ai`, a deadline) lets a `kind:
      question` step wait on an answer while the rest of the chain runs
      (k191); a ready question step resolves it -- waiting on `open`,
      finishing on `answered`, doing what the asker wrote on `overdue`
      (k200). `karr-foundation plan` writes a chain from a YAML/JSON document
      on stdin or `--input`, validating ids, edges, kinds, prechecks and the
      cycle check before a ref is written, and carries the coordination
      agent's prompt (k213). That coordination agent is the fleet's judgement
      layer (k210): marked `role: coordinator`, it runs almost never -- once
      at the end of a tick, carrying every deviation the tick met -- and
      writes an assignment (repository to an ordered agent list with an
      explicit `WAIT`) that karr-foundation then reads with no AI in the hot
      path. A fleet that marks no coordinator behaves exactly as before.

    - karr-foundation learned per-repo modes and named agents (k185, k187,
      k188). `mode: ticket` picks one card by `karr pick`'s ranking minus
      the claim, names it in the prompt and as `$KARR_TASK`, runs the agent
      once and reports what happened to that card; a stall costs one attempt
      and auto-blocks at `max_attempts` (k185). The local config defines
      named agents -- `command`, `kind`, `probe_every`, a prose `description`
      the routing agent reads -- picked per board with `agent:`, and
      definitions stay local and never sync (k188). A `kind: claude-code`
      run is classified from the JSON result object it ends with, not its
      transcript, so a spent turn budget is a reason to run again while a
      provider status backs the board off, and availability is tracked as ok
      / failing-since-X with a next attempt (k187, k160's last-line rule).

Changes  view on Meta::CPAN

      (#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

Changes  view on Meta::CPAN

      `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

Changes  view on Meta::CPAN

      `karr set-refs` refuses, so no refspec can reach them at all. Board
      state, including the `refs/karr/log/*` activity log, is unaffected and
      still syncs. Locks left at the old address by an older karr, or pulled
      from a remote that still has them, are not acted on — a lock from another
      clone says nothing about this process — but `karr unlock` lists them,
      marked as strays, and clears them.
    - Fixed the last race in `karr delete`'s claim guard. `App::karr::Git`
      could only delete a ref by name, through a libgit2 call that takes no
      expected-old OID, so no delete could be guarded: re-reading the task and
      re-applying the claim rule closed the minutes-long window behind the
      confirmation prompt but left the microseconds after it, in which a claim
      landing on the card was deleted along with it. There is now a
      compare-and-swap delete (`delete_ref_cas`) reporting the same retryable
      outcomes the existing retry loop understands, and the guarded delete
      paths — `karr delete` and giving back a `karr pick` lock — go through it.
      The unguarded `delete_ref` is unchanged, because `karr destroy` and
      breaking a lock deliberately remove whatever is there.
    - Fixed the push insurance retrying a push the remote had already refused.
      When a command dies after writing refs, karr pushes from an END block so
      the writes are not stranded; that path still made three attempts a second
      apart at a refusal the far side had already given its answer to, and then

Changes  view on Meta::CPAN

      string in strptime" to stderr, on every single `karr pick`. Offsets and
      fractional seconds are now parsed and normalised to UTC, and the
      warnings are gone with them.
    - Fixed `karr delete` without `--yes` printing "Use of uninitialized value
      $answer" twice when stdin reaches EOF without an answer — which is every
      agent and CI invocation that forgets the flag — and then silently
      skipping the task with exit 0. When there is no answer and stdin is not
      a terminal it now refuses with "No answer on stdin and stdin is not a
      terminal. Re-run with --yes." and exits 1, the way karr's other
      destructive commands already refuse without `--yes`. Ctrl-D at a real
      terminal still means "no", and piping `y` or `n` into the prompt still
      works.
    - Fixed `karr move , todo`, `karr edit ,` and `karr delete ,` — an id list
      that contains no ids — doing nothing at all and exiting 0. The comma got
      past the "an id is required" check because it is a non-empty string, and
      then split to an empty list, so the per-id loop never ran and no error
      was raised for the exit-code contract to classify. All three now report
      a usage error and exit 2.
    - Fixed a push the remote rejected being reported as a completed sync.
      libgit2 returns success from `git_remote_push` even when the server
      refused every ref — a pre-receive hook, a protected ref, a

Changes  view on Meta::CPAN

      (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

MANIFEST  view on Meta::CPAN

t/229-context-only-archived-excluded.t
t/23-board-store.t
t/231-writes-that-change-nothing.t
t/232-claim-timeout-zero-never-expires.t
t/233-pick-fixed-date-due.t
t/234-board-archived-and-terminal.t
t/235-contradicting-invocations.t
t/236-delete-dependent-warning.t
t/238-append-body-blank-line.t
t/24-ref-first-board-access.t
t/241-delete-prompt-flush.t
t/242-delete-cross-board-warning.t
t/243-empty-argument.t
t/247-abbreviated-option-value.t
t/248-delete-json-prompt-channel.t
t/249-combined-output-order.t
t/25-init-ref-first.t
t/251-pick-compact.t
t/252-list-filter-limit-options.t
t/252-list-unclaimed-agrees-with-pick.t
t/253-log-json-empty-stream.t
t/254-compact-output-role.t
t/255-config-show-defined-values.t
t/256-dashed-option-after-flag.t
t/26-skill-share-dir.t

ex/README.md  view on Meta::CPAN

Moved task 5: backlog -> review
Handed off task 5 -> review (claim released)
START command=.../drain-agent.sh
drain-agent: working on #1
Moved task 1: backlog -> done
drain-agent: nothing to pick
```

- **webapp runs in `mode: ticket`.** karr-foundation picks the next assignable
  card itself and tells the agent about it (`$KARR_TASK`, plus a line in the
  prompt). `fake-agent.sh` moves it to review and hands it off under a fresh
  claim, releasing the claim at the end.
- **docs-site runs in `mode: drain`.** The agent picks its own work
  (`drain-agent.sh` uses `karr pick`). It works each card all the way to
  `done`, because a drain run only terminates when no actionable cards are
  left — an agent that parks cards in review would keep the drain going.

Run it again and watch `Moved task 5: review -> review`: a card in review is
still *actionable*, so it keeps getting picked until somebody moves it to a
terminal state. Finish it yourself with:

ex/README.md  view on Meta::CPAN

webapp
  3 tasks
  backlog:1  todo:2
```

Now the opt-in, and that is **in the repository itself**:

```yaml
# /srv/webapp/.karr
mode: ticket
command: my-agent --task "$KARR_TASK" --prompt "$PROMPT"
max_runtime: 1800
max_attempts: 2
```

### The three modes: `drain`, `single`, `ticket`

`mode:` says what **one pass over a repository** is:

| Mode | Meaning |
|---|---|

ex/README.md  view on Meta::CPAN

not blocked, not held by a live claim; class, then priority, then identifier).
These rules live in *one* role that both `karr pick` and foundation's picker
compose. Until that was the case, they stood written out twice, and only the
fact that they were copied from each other kept them in agreement — while a
coordinator that names a different card than the board would hand out argues
with its own board.

The card is told to the agent **twice**: as the closing sentence in the
`$PROMPT`, which names the identifier, and as `$KARR_TASK` for a command
template that wants the bare number. That the identifier is spliced into the
prompt and not the prompt itself writes `$KARR_TASK` has a prosaic reason: the
prompt reaches the agent as `$PROMPT`, and `/bin/sh` doesn't rescan an expanded
value — a prompt that contained `$KARR_TASK` would give the agent those ten
characters.

And: **foundation names the card, it doesn't claim it.** The claim is the
agent's work session, minted with `karr agentname` and reused across its own
`move` and `handoff` calls. A claim invented by foundation couldn't be handed to
the agent without a protocol of its own. The board's `.karr.lock` and the rule
"one agent per repository" keep everyone else away for the duration of the run
anyway. An agent that dies mid-run leaves at most its own claim — resolved by
`claim_timeout` or `karr unlock` — and costs one attempt on foundation's
counter.

ex/README.md  view on Meta::CPAN

If there is no assignable card at all, **no agent** runs in ticket mode;
`.karr.log` gets `TICKET none assignable`, and the result is `idle`. `--force`
and `on_idle: always-run` force the check, not a run without a card.

### Look first, then run

```console
$ karr-foundation --dry-run --verbose
sync --pull /srv/webapp
[2026-08-18T05:35:34] 1565842: TICKET task#1
[2026-08-18T05:35:34] 1565842: START command=my-agent --task "$KARR_TASK" --prompt "$PROMPT"
exec in /srv/webapp: my-agent --task "$KARR_TASK" --prompt "$PROMPT"
[2026-08-18T05:35:34] 1565842: DRY-RUN (skipped)
[2026-08-18T05:35:34] 1565842: STALL task#1 — no report from the agent
```

A dry run starts nothing and writes nothing — no agent, no `.karr.state`, no
`.karr.log`, and not even the pull that the first line announces. It's also mute
without `--verbose`: those lines are the verbose stream, not a report.

Then the real thing:

```console
$ karr-foundation --verbose
sync --pull /srv/webapp
[2026-08-18T05:35:34] 1565844: TICKET task#1
[2026-08-18T05:35:34] 1565844: START command=my-agent --task "$KARR_TASK" --prompt "$PROMPT"
exec in /srv/webapp: my-agent --task "$KARR_TASK" --prompt "$PROMPT"
working on #1 as fund-duty
[2026-08-18T05:35:35] 1565844: END elapsed=1s exit=0
```

The agent's output is streamed to the terminal in real time when there is one
(or with `--verbose`), and always appended to `.karr.log`. The card has moved,
so there's nothing more to say. `.karr.state` now carries the board fingerprint
against which the next tick compares:

```json

ex/README.md  view on Meta::CPAN

without the chain, it keeps doing. Only the commands that mean nothing without a
hub (`ask`, `answer`, `chain`) fail hard.

### `on_drained` — the hook that `karr` deliberately doesn't understand

When a board has been drained — no workable card left, everything done, archived
or blocked — foundation may run exactly one command in it:

```yaml
# /srv/gate/.karr
command: my-agent --prompt "$PROMPT"
on_drained: ./release-gate.sh
on_drained_max_runtime: 1800
on_drained_max_rounds: 3
```

```console
$ karr-foundation --verbose
sync --pull /srv/gate
[2026-08-18T05:35:54] 1566032: START command=my-agent --prompt "$PROMPT"
exec in /srv/gate: my-agent --prompt "$PROMPT"
no card assigned, nothing to do
[2026-08-18T05:35:54] 1566032: END elapsed=0s exit=0
[2026-08-18T05:35:54] 1566032: START role=hook command=./release-gate.sh
exec in /srv/gate: ./release-gate.sh
release gate in /srv/gate (role=hook)
[2026-08-18T05:35:54] 1566032: END elapsed=0s exit=0
[2026-08-18T05:35:54] 1566032: ON-DRAINED exit=0
```

**`karr` doesn't know what this command does, and must not know it.** In the

ex/README.md  view on Meta::CPAN


`config.yml` keys:

| Key | Meaning |
|---|---|
| `dirs:` | explicit board repositories |
| `scan:` | parent directories whose direct children are checked for a board |
| `concurrent:` | machine cap of simultaneous boards with an agent (default 1) |
| `hub:` | the repository that carries `refs/karr-foundation/*` |
| `agents:` / `default_agent:` / `probe_every:` | named agent definitions, the fallback agent, the default retry interval |
| `default_command:` / `default_prompt:` | fleet-wide command and fleet-wide prompt |
| `mode:`, `claude:`, `claude_bin:`, `claude_max_turns:`, `claude_permission_mode:`, `on_drained:`, `on_drained_max_runtime:`, `on_drained_max_rounds:` | fleet-wide defaults for the `.karr` keys of the same name |

`.karr` keys per repository (each wins over the fleet-wide value):

| Key | Meaning |
|---|---|
| `command:` | the agent command; a shell template, `$PROMPT` and `$KARR_TASK` are exported into it |
| `prompt:` | the instruction passed as `$PROMPT` |
| `agent:` | a named agent from `agents:` |
| `claude:` / `claude_bin:` / `claude_max_turns:` / `claude_permission_mode:` | synthesizes the canonical claude command (opt-in) |
| `mode:` | `drain` (default), `single`, `ticket`; `drain: true\|false` is the older spelling of the first two |
| `on_idle:` | `skip` (default) or `always-run` |
| `max_runtime:` | SIGKILL per command in seconds (`0` = no timeout) |
| `max_attempts:` | stalls on a card until it is auto-blocked (default 2) |
| `max_iterations:` | hard cap for drain iterations (default 50) |
| `cooldown_base:` / `cooldown_max:` | cooldown minutes at level 0 (default 1) and the ceiling (default 64) |
| `error_patterns:` | additional case-insensitive substrings that count as common error |
| `on_drained:` / `on_drained_max_runtime:` / `on_drained_max_rounds:` | the domain hook, its budget and its round cap |

ex/setup.sh  view on Meta::CPAN

fi

# --- fleet-hub ---------------------------------------------------------------
make_repo fleet-hub

# --- .karr files -------------------------------------------------------------
if [ ! -f "$EX/webapp/.karr" ]; then
  cat > "$EX/webapp/.karr" <<EOF
mode: ticket
agent: demo
prompt: "You are a demo agent. Work the assigned card, move it to review and hand it off under your own claim."
max_attempts: 2
EOF
fi
if [ ! -f "$EX/docs-site/.karr" ]; then
  cat > "$EX/docs-site/.karr" <<EOF
mode: drain
command: $EX/bin/drain-agent.sh
prompt: "You are a demo agent. Pick the next assignable card and work it all the way to done, so the drain run terminates."
max_attempts: 2
EOF
fi

# --- karr-foundation config --------------------------------------------------
cat > "$EX/config.yml" <<EOF
# Generated by ex/setup.sh — points at the sandbox repos with absolute paths.
dirs:
  - $EX/webapp
  - $EX/docs-site

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

      my $sep = $content =~ /\n$/ ? "\n" : "\n\n";
      $out = $content . $sep . $md;
    }
  }

  eval { $file->spew_utf8($out); 1 }
    or user_error( "Could not write $file: ", clean_error($@) );

  # stdout belongs to the payload when an output flag claims it, so the
  # confirmation goes to stderr there. Same answer #248 gave for `delete`'s
  # prompt and for the same reason: `karr context --json --write-to AGENTS.md
  # > ctx.json` has to leave behind a file that decodes whole, and a
  # key=value rendering that carries one line of prose is not key=value.
  # Without an output flag stdout is prose anyway, so the line stays put.
  if ( $self->json || $self->compact ) {
    printf STDERR "Context written to %s\n", $self->write_to;
  }
  else {
    printf "Context written to %s\n", $self->write_to;
  }
}

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

C<--compact> prints the board's four numbers and nothing else -- one
C<key=value> per line, under the same names the C<--json> summary uses, with no
headings, no sections and no sentinels:

    board_name=karr
    total_tasks=41
    active=7
    blocked=1
    overdue=0

That is the reading of a briefing that fits in a prompt header or a status
line, and it is what C<context --compact> was silently failing to do while
C<--compact> was declared for every command in L<App::karr::Role::Output>
(#254). It shapes what is printed, not what is written: with C<--write-to> the
file still receives the Markdown block, because those sentinels are an interop
contract with kanban-md (see L</FILE UPDATE MODE>) and a compacted block would
be one neither tool could find again.

C<--json> and C<--compact> do not compete with C<--write-to> and never did
(#260). C<--write-to> is a side effect; the output flags decide what stdout
carries. All three combinations write the same Markdown block to the file and
differ only in what is printed:

    karr context --write-to AGENTS.md              Context written to AGENTS.md
    karr context --json    --write-to AGENTS.md    the JSON payload
    karr context --compact --write-to AGENTS.md    the four numbers

With an output flag the C<Context written to ...> confirmation goes to
B<stderr>, so C<< karr context --json --write-to AGENTS.md > ctx.json >>
leaves behind a file that decodes whole -- the channel rule C<delete>'s prompt
follows for the same reason (#248). Without one, stdout is prose anyway and
the line stays there.

Only C<archived> tasks are left out of the summary. Finished work still counts
towards the reported total and is still reported as blocked if it is, which is
the rule kanban-md applies to the same block.

=head1 SECTIONS

The generated context can include C<in-progress>, C<blocked>, C<overdue>,

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

    # Before the confirmation, never after it. The point of the warning is that
    # it can still change the answer, and the operator who reads it and types
    # "n" is the one it worked on -- so it deliberately does not wait for the
    # write the way App::karr::Role::DependencyCheck/dependency_report waits for
    # its own. That rule ("a warning about a move that then lost its
    # compare-and-swap is a warning about something that did not happen") is
    # about a card that survives to be read afterwards. Here the write is what
    # removes the card, so a warning that waits for it is a warning about
    # something nobody can choose to keep any more.
    #
    # Under --yes there is no prompt to precede and it comes anyway: --yes is
    # the mode agents delete in, so a warning only on the interactive path warns
    # exactly where nobody is left to read it.
    my @dependents = $self->_dependent_warnings($task);
    # And what this card says about the boards it cannot see (#242): the far end
    # of an escalation is a tag on the card being deleted, so naming it costs
    # nothing remote.
    my @cross = $self->_cross_board_warnings($task);
    # The channel App::karr::Role::DependencyCheck argues for one module over,
    # rather than a third convention: the human copy on STDERR so STDOUT stays
    # parseable, --quiet silencing that copy, and --json carrying the identical

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

    );

    unless ($self->yes) {
      # STDERR, and not only under --json (#248). The question used to go to
      # STDOUT, which put a bare `Delete task 1: A? [y/N] ` in front of the
      # result object: the object was there, but the stream as a whole would
      # not decode, and this is the command whose output a caller is most
      # likely to read before doing something irreversible.
      #
      # The channel is unconditional rather than a branch on --json, for three
      # reasons. A prompt is not a result -- `deleted` below is the result, and
      # the question is dialogue, which is what STDERR is for; the rule
      # App::karr::Role::DependencyCheck states one module over ("the human
      # copy goes to STDERR so STDOUT stays parseable") is likewise
      # unconditional, only its *suppression* depends on an option. Second, the
      # non-JSON path has the same defect in a quieter form: `karr delete 1 >
      # kept.txt` wrote the question into the file, so the operator at the
      # terminal was asked nothing and waited at a blank cursor. And third,
      # making the channel depend on a flag means the fix only reaches the
      # caller who remembered the flag.
      #
      # Rejecting `--json` without `--yes` outright was the other candidate. It
      # would have deleted a live answer: `deleted => false` with the two
      # warning keys beside it is exactly the shape #236 and #242 built for a
      # card the operator declined to delete, and under --yes there is no
      # prompt to decline at all, so that shape would become unreachable. It
      # also refuses `printf 'y\nn\ny\n' | karr delete 1,2,3 --json`, a
      # per-card answer that --yes cannot express because --yes is
      # all-or-nothing. --json names an output format and --yes a confirmation
      # policy; they are orthogonal, not the contradicting pair #235 refuses.
      printf STDERR "Delete task %d: %s? [y/N] ", $task->id, $task->title;
      # The question has to be out before the read that waits for its answer,
      # and this printf alone does not put it there: it ends without a newline,
      # so nothing in the buffering flushes it (#241).
      #
      # Whether that showed depended on where stdin came from, which is why it
      # went unnoticed for so long. With stdin on a terminal PerlIO flushes the
      # line-buffered handles when it fills its read buffer, so the prompt got
      # out by somebody else's courtesy -- a detail of the implementation, not
      # a promise. With stdin anywhere else -- a pipe, a file, an agent harness
      # feeding answers -- nothing does it, and the question sits in the buffer
      # until the next newline or process exit pushes it out, which is after
      # karr has already acted on the answer. `karr delete 1 < answers` in a
      # terminal printed the question and the outcome together at the end.
      #
      # The flush moves with the question, and what it is worth changed under
      # it: #249 turned autoflush on for STDOUT and STDERR in
      # App::karr::Encoding::enable_std_utf8, so on the CLI path the question is

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

      # <STDIN> returns undef at EOF, and karr used to run straight on into
      # `chomp $answer` -- two "Use of uninitialized value $answer" warnings on
      # stderr, then "Skipped task 2: d", for every agent or CI run that forgot
      # --yes (ticket #73). What the right answer to a non-answer is depends on
      # where stdin came from:
      #
      #   a terminal   the user pressed Ctrl-D. That is "no": skip the task and
      #                exit 0, the same as typing n, and now without warnings.
      #
      #   anything else  nobody is there and nobody will be, so there is no
      #                point pretending the prompt happened. Refuse and say what
      #                to do, the way karr's other destructive commands refuse
      #                without --yes and the way kanban-md refuses when
      #                term.IsTerminal is false.
      #
      # An answer that *is* there is honoured either way, so piping "y" or "n"
      # into `karr delete` keeps working.
      die "No answer on stdin and stdin is not a terminal. Re-run with --yes.\n"
        if !defined $answer && !-t STDIN;

      $answer = '' unless defined $answer;

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

        # the pair is their only channel, and this is the case they were for:
        # `deleted => false` sits beside them and says plainly that the delete
        # they named did not happen.
        return { id => $task->id, title => $task->title, deleted => \0,
                 @warning_report };
      }
    }

    $self->delete_task_guarded($task->id, undef);
    printf "Deleted task %d: %s\n", $task->id, $task->title unless $self->json;
    # Reported here and not before the prompt, because a card the operator
    # answered "n" for had its claim examined but not overridden. The two
    # check_claim calls on this path -- the one above and the one inside
    # delete_task_guarded -- record into the same slot, so this is still one
    # line (#177). Deleting the card is also the one case where nothing survives
    # to be read afterwards, which is what makes the trace matter most here.
    return { id => $task->id, title => $task->title, deleted => \1,
             @warning_report,
             $self->expired_claim_report( $task->id ) };
  });

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

Permanently removes one or more tasks' refs from the board. This is the
destructive alternative to L<App::karr::Cmd::Archive>, which only changes the
status to C<archived>.

=head1 OPTIONS

=over 4

=item * C<--yes>

Skips the interactive confirmation prompt for each task. Required whenever
nothing will answer that prompt: if stdin is not a terminal and carries no
answer, the command refuses rather than guessing.

=back

The prompt itself goes to STDERR, on every path and not only under C<--json>: a
question is dialogue, not a result, and STDOUT belongs to the result. That is
what keeps C<< karr delete ID --json >> decodable as a whole when the answer is
typed rather than passed as C<--yes>, and what keeps C<< karr delete ID >
kept.txt >> from writing the question into the file instead of showing it to the
operator waiting for it. The outcome stays on STDOUT: C<Deleted task ...> or
C<Skipped task ...> in plain mode, and the result object -- with C<deleted> true
or false -- under C<--json>.

=head1 CLAIMS

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

=head1 DEPENDENTS

Before an id is removed the board is searched for the cards that point at it --
a C<depends_on> entry naming it, or C<parent> set to it -- and each one is named
on STDERR as a C<Warning:>, offering C<karr archive> as the way to keep the card
instead. The delete then proceeds: karr warns about dependencies rather than
blocking on them (L<App::karr::Role::DependencyCheck>), and this warning exists
to send a caller from the destructive way to the soft one, not to refuse them
the destructive one.

It comes before the confirmation prompt, so it can still change the answer, and
it comes under C<--yes> as well, which is the mode agents delete in. C<--json>
carries the identical sentences as C<dependent_warnings> in the result object
instead, and C<--quiet> silences the STDERR copy.

The search is board-local: it reads the cards on this board. What the card
being deleted says about I<other> boards is a second warning, below.

=head1 CROSS-BOARD LINKS

A card on another board waiting on this one through a C<needs:> link

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

}

# The payload as it arrives on stdin, as characters.
#
# STDIN is the one input edge App::karr::Encoding leaves without a PerlIO layer
# (App::karr::Cmd::Restore reads it the same way), so the decode is explicit and
# happens exactly once.
sub _payload_from_stdin {
  my ($self) = @_;

  # A terminal has nothing queued and would just sit there with no prompt, so
  # the shape that was a usage error before stays one instead of becoming a
  # hang.
  die "Usage: karr set-refs REF CONTENT...\n" if -t STDIN;

  binmode STDIN, ':raw';
  my $content = do { local $/; <STDIN> };

  # An empty stdin is not an empty payload: `karr set-refs REF < /dev/null`, or
  # a generator upstream that produced nothing, is a mistake, and storing '' for
  # it would report success. The deliberate way to store an empty payload is

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

  . 'the task with a reason.';

# The same, for a ticket-mode run. It cannot be $DEFAULT_PROMPT: that one opens
# by telling the agent to pick its own work, which is the one thing a run that
# has already been given a card must not do.
our $DEFAULT_TICKET_PROMPT =
    'Use the karr-coordinator skill: work on the one task named below, '
  . 'complete it, and move it forward. If you cannot proceed, block the task '
  . 'with a reason.';

# Appended to whatever prompt was resolved, ticket id spliced in by foundation
# itself. It has to be foundation that splices: the prompt reaches the agent as
# $PROMPT and /bin/sh does not rescan an expanded value, so a prompt writing
# $KARR_TASK would hand the agent those ten characters (#159). Last, not first,
# because it has to win over an operator prompt that says "pick the next task".
our $TICKET_ASSIGNMENT =
    'The task for this run is #%s: work on that one task and no other. Claim '
  . 'it before you start, and stop when it is done, handed off or blocked. Do '
  . 'not pick up another task.';

option config => (
  is     => 'ro',
  format => 's',
  doc    => 'Path to config file (default: ~/.config/karr-foundation/config.yml)',
);

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

  return $exit;
}

# ---------------------------------------------------------------------------
# Writing the chain (#213)
# ---------------------------------------------------------------------------

# The chain had no command of its own: App::karr::Foundation::ChainStore's
# write_chain was Perl API, so the one writer that is not a person -- the
# coordination agent -- was handed a `perl -MApp::karr::Foundation::ChainStore
# -e ...` one-liner in its prompt and asked to type it out. That made a storage
# API somebody's interface, where everything else karr asks an agent to do is a
# command, and it meant a rename inside that class broke a prompt rather than a
# call: silently, and only on the tick where a plan was wanted.
#
# It takes a document rather than options because a chain is a DAG and a DAG is
# nested: `--step id=1,kind=ticket,needs=2,3` would be YAML with a worse syntax
# and a parser of its own, and the writer that matters most already produces
# structure. Stdin (or --input) is where `karr restore` takes a snapshot from,
# for the same reason, and the document is read as YAML -- which reads JSON
# too, so an agent that emits JSON has emitted a chain document.
#
# It replaces the chain rather than adding to it, because that is what

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


  if ( defined $self->input ) {
    # An unreadable --input is the caller's path, not karr's: Path::Tiny's own
    # error would hand them this file and line instead (#77).
    $payload = try { path( $self->input )->slurp_utf8 }
    catch {
      user_error( 'Could not read ' . $self->input . ': ' . clean_error($_) );
    };
  }
  else {
    # A terminal has nothing queued and would sit there with no prompt, so the
    # invocation that forgot its input stays a usage error instead of becoming
    # a hang -- the reading App::karr::Cmd::SetRefs makes of the same edge.
    user_error( 'Usage: karr-foundation plan < chain.yml: the chain '
      . 'document arrives on stdin, or from the file --input names' )
      if -t STDIN;

    # STDIN is the one input edge App::karr::Encoding leaves without a PerlIO
    # layer, precisely so this decode is explicit and happens exactly once.
    binmode STDIN, ':raw';
    my $octets = do { local $/; <STDIN> };

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

# claude_permission_mode override the defaults (per-repo, then global).
sub _claude_command {
  my ( $self, $karr ) = @_;
  my $cfg = $self->_config_data;
  my $bin   = $karr->{claude_bin}             // $cfg->{claude_bin}             // 'claude';
  my $turns = $karr->{claude_max_turns}       // $cfg->{claude_max_turns}       // 30;
  my $perm  = $karr->{claude_permission_mode} // $cfg->{claude_permission_mode} // 'bypassPermissions';
  return qq{$bin -p "\$PROMPT" --permission-mode $perm --max-turns $turns};
}

# The agent instruction exposed as $PROMPT. .karr 'prompt' > config
# 'default_prompt' > the built-in default.
#
# With a $ticket the built-in default changes (the ordinary one opens by
# telling the agent to pick its own work) and the assignment sentence is
# appended to whatever prompt was resolved. Appending rather than replacing
# keeps a configured prompt doing its job — it is usually about which skill to
# use and how to report — while the last sentence, which is the one that wins
# with a language model, is the one naming the card. Without this the mode
# would be `drain: false` with extra steps: the agent would never learn which
# ticket it was given.
sub _prompt_for {
  my ( $self, $karr, $ticket ) = @_;
  my $configured = $karr->{prompt} // $self->_config_data->{default_prompt};
  return $configured // $DEFAULT_PROMPT unless defined $ticket;
  return ( $configured // $DEFAULT_TICKET_PROMPT ) . "\n\n"
       . sprintf( $TICKET_ASSIGNMENT, $ticket );
}

1;

__END__

=pod

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

  hub: /path/to/hub-repo    # the repository carrying refs/karr-foundation/*
  routing: >-               # prose for the coordination agent, never parsed
    minimax is cheap; never hand it a release.

B<Per-repo .karr file:>

  claude: true              # synthesize the canonical claude command (opt-in)
  claude_bin: claude        # binary for claude: true (default: claude)
  claude_max_turns: 30      # --max-turns for claude: true (default: 30)
  claude_permission_mode: bypassPermissions   # (default: bypassPermissions)
  prompt: >-                # agent instruction, exposed as $PROMPT
    Use the karr-coordinator skill: pick the next actionable task and move it.
  command: claude -p "$PROMPT"   # explicit command; wins over claude: true
  on_idle: skip             # 'skip' (default) | 'always-run'
  max_runtime: 1800         # seconds: per-command SIGKILL (0 = no limit)
  mode: drain               # drain (default) | single | ticket
  drain: true               # older spelling of mode: true=drain, false=single
  max_attempts: 2           # stalls on one task before auto-block (default: 2)
  max_iterations: 50        # hard cap on drain iterations (default: 50)
  cooldown_base: 1          # cooldown minutes at level 0 (default: 1)
  cooldown_max: 64          # cooldown ceiling in minutes (default: 64)
  error_patterns:           # extra case-insensitive substrings -> common-error
    - my custom api error   # (added to the defaults; matched as written)
  on_drained: ./release-gate.sh   # run when the board has no work left
  on_drained_max_runtime: 1800    # seconds for that command (0 = no limit)
  on_drained_max_rounds: 3        # see "The domain hook" (0 = no cap)

  agent: minimax            # a named agent from the config's 'agents:' section

C<claude>, C<claude_bin>, C<claude_max_turns>, C<claude_permission_mode>,
C<command>, C<mode>, C<on_drained>, C<on_drained_max_runtime>,
C<on_drained_max_rounds> and C<prompt>/C<default_prompt> may also be set
globally in the config file; the per-repo F<.karr> value wins.

B<Named agents.> A board has one C<command>. A fleet has several agent commands
with different strengths and different failure modes, so the config can name
them and a board can pick one:

  agents:
    minimax:
      command: claude_with_minimax
      kind: claude-code       # the invocation contract; default: shell

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

merge policy of its own for an id that is already there and a state that has
already been reached. The plan is what the planner currently thinks. What makes
replacing safe is the guard: a chain that still has a step in state C<running>
is refused unless C<--force>, and the whole document -- every step, the ids,
the edges, the cycle check -- is validated before the first ref is written, so
a chain karr will not take leaves the one in the hub exactly as it was
(L<App::karr::Foundation::ChainStore/validate_chain>).

The command is what an agent gets because everything else karr asks an agent to
do is a command. Before it, writing a chain was C<write_chain> from Perl and
the coordination agent was handed that one-liner in its prompt to type out --
the one place karr gave an agent Perl instead of a call, where a rename in a
storage class broke a prompt and nothing said so (#213).

B<The question mailbox.> A question is a file with an answer field, not a
dialogue, which is what removes the special case for "a human happens to be
present". C<karr-foundation ask> writes one into the hub and returns; the chain
carries on with everything that does not depend on it, and only the steps that
do wait. Whoever answers -- a person at a terminal, a chat bridge, the
coordination agent -- types C<karr-foundation answer ID ANSWER> and needs to
know nothing about the chain. One mailbox, many writers.

  karr-foundation ask "Which registry do we publish to?" \

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

requirements -- none of which belongs in a kanban tool, and all of which would
otherwise arrive here as rules about what an exit code means. So the exit code
is written to F<.karr.log> and F<.karr.state> and interpreted by nobody: a hook
that fails does not park the board, does not mark the board's agent failing,
and is never the run's C<last_error>. It is not an agent run and is not
classified as one -- no report is read out of it, no error pattern is matched
against it, no ticket is assigned to it.

It is told where it is and nothing else: C<KARR_REPO>, and C<KARR_ROLE=hook> so
that C<karr> writes of its own land in their own activity log rather than
counting as the agent's engagement with a card. C<PROMPT> is empty (the prompt
is the agent's instruction) and so is C<KARR_TASK>. It runs in the board's
directory, under the board's own F<.karr.lock>, with the same process-group
kill and the same tee to F<.karr.log> an agent gets -- a gate that backgrounds
a build must not outlive the run that started it -- but with its own budget,
C<on_drained_max_runtime>, because how long an agent may take says nothing
about how long a release gate may.

B<Drained> is a fact about the board, not a name for an outcome: no actionable
task is left on it -- everything done, archived or blocked. That is deliberately
the same question C<--force> and C<< on_idle: always-run >> are answers to, and

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

# ---------------------------------------------------------------------------


has wanted => (
  is      => 'ro',
  default => sub { [] },
);

# Which entries have already been recorded, so one tick that meets the same
# deviation twice -- _plan_repos and _process_repo both resolve every board --
# does not say it twice in the prompt.
has _seen => (
  is      => 'ro',
  default => sub { {} },
);


sub want {
  my ( $self, %what ) = @_;
  return 0 unless $self->configured;
  my $reason = $what{reason};

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

    qw( step repo reason );
  return 0 if $self->_seen->{$key}++;
  push @{ $self->wanted }, {
    reason => $reason,
    ( defined $what{step} ? ( step => "$what{step}" ) : () ),
    ( defined $what{repo} ? ( repo => "$what{repo}" ) : () ),
  };
  return 1;
}

# One deviation as the prompt says it: what it is about, then what it was.
sub _line {
  my ( $want ) = @_;
  my $what = defined $want->{step} ? "step $want->{step}"
           : defined $want->{repo} ? $want->{repo}
           :                         undef;
  return defined $what ? "$what: $want->{reason}" : $want->{reason};
}

# ---------------------------------------------------------------------------
# The call

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

  } catch {
    warn "karr-foundation: the coordination agent failed to start: "
       . clean_error($_) . "\n";
    0;
  };
  $f->_release_lock( $hub );
  return $ran;
}

# The invocation itself, once the tick has decided it may happen: the agent's
# own command under its own contract (#188), the deviations as its prompt, and
# its own result object as the verdict (#187).
sub _invoke {
  my ( $self, $hub, $name, $wanted ) = @_;
  my $f   = $self->foundation;
  my $inv = $f->_agents->invocation( $name );

  my ( $exit, $output ) = $f->_run_command(
    $hub, $f->_load_karr( $hub ), $inv->{command}, undef, $inv,
    role   => 'coordinator',
    prompt => $self->prompt( $wanted ),
  );

  my ( $err, $ended );
  my $report = $f->_run_result( $output );
  if ( $report ) {
    ( $err, $ended ) = $f->_result_error( $report );
    # The same reading the drain makes of an exit the report does not account
    # for: the report is the agent's, the exit code may be a wrapper's.
    $err //= "exit=$exit" if $exit != 0 && !$report->{is_error};
    $f->_append_log( $hub, $f->_result_line( $report, $ended ) );

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

  }

  $f->_agents->record_success( $name );
  $f->_append_log( $hub, 'COORDINATION ' . ( $ended // 'done' ) );
  print "the coordination agent '$name' finished ("
      . ( $ended // 'no report' ) . "); the next tick runs what it wrote\n";
  return 1;
}

# ---------------------------------------------------------------------------
# The prompt
# ---------------------------------------------------------------------------


sub prompt {
  my ( $self, $wanted ) = @_;
  my $f   = $self->foundation;
  my $hub = $f->_hub_git ? $f->_hub_git->dir : '(none)';

  my @out;
  push @out, <<'INTRO';
You are the coordination agent of this karr fleet -- its judgement layer.
karr-foundation works through written plans on its own and calls you only when
a plan is missing or has broken; between two of those calls no AI runs at all.
Everything you write is read back by machinery that runs without you, so write

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

  return 'failing since ' . _stamp( $av->{failing_since} )
    . ', next attempt at ' . _stamp( $av->{next_attempt} )
    . ( defined $av->{last_error} ? " ($av->{last_error})" : '' );
}

# The three things it may write, each with the exact shape karr reads back. Two
# of them are commands and are spelled out as commands: an agent that has to
# guess at an interface writes something nobody can execute. Until #213 the
# chain was the exception -- there was no command for it, so this block carried
# a perl -e one-liner against ChainStore, which made a storage API an agent's
# interface and left every rename in that class breaking a prompt instead of a
# call.
sub _writes_block {
  my ( $self ) = @_;
  my $file = $self->assignment_file;
  my $karr = $self->_foundation_command;
  return <<"WRITES";
WHAT YOU MAY WRITE

1. THE ASSIGNMENT -- $file
   Repository path to the ordered list of agents that may work that board.

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

writes it makes land in their own activity log instead of counting as a board
agent's engagement with a card (#158).

Its result decides the agent's availability exactly as a drain's does: a
reported error or a non-zero exit marks it C<failing> and every board on it
waits for one probe interval, anything else says it works. The transcript is
B<not> scanned. That scan exists for a board run that moved nothing and
printed a rate limit (#160); this run moves no board by definition, and a
planner that prints a backlog would trip it on the backlog's own words.

=head2 prompt

    my $text = $coordinator->prompt( \@wanted );

The instruction the coordination agent is given, as C<$PROMPT>: why it was
called, where the fleet's files are, which agents exist here with their
availability and their prose, the operator's own prose about how to use them
(config key C<routing:>), the shapes it may write, and the boundaries it may
not cross.

There is deliberately no key to replace this text with another. What an
operator has to say about routing is prose and belongs in C<routing:>, where
the agent reads it in context; a second prompt key would be a place to
overwrite the part that says what karr can actually read back.

=head1 SUPPORT

=head2 Issues

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

=head2 IRC

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

  # every board that names no agent -- is the historical path: the octets the
  # command printed, verbatim, to the log and the terminal.
  my $render = ref $agent eq 'HASH' ? $agent->{render} : undef;

  # Environment for the child (and all karr calls it spawns). The child inherits
  # it across the fork/exec below, so a command template — including the
  # synthesized claude command — expands $PROMPT, ${KARR_REPO}, $KARR_ROLE and
  # every other variable foundation itself was started with as ordinary shell
  # parameters. %ENV is a byte boundary owned by App::karr::Encoding, so each
  # value is encoded through to_octets_for_env before the assignment (#167):
  # a non-ASCII prompt would otherwise emit "Wide character in setenv" on
  # stderr and the bytes the child receives would depend on the IO layers in
  # scope at the call site.
  local $ENV{KARR_REPO} = to_octets_for_env("$repo");
  local $ENV{KARR_ROLE} = to_octets_for_env($role);

  # The prompt is the board agent's instruction, so only the board agent gets
  # the board's one. A hook handed a prompt telling it to pick the next
  # actionable task would be told to do the one thing it is not there for, and
  # every karr write it made would land in the agent's activity log -- which is
  # the evidence the auto-block reads. KARR_ROLE keeps those apart, and this
  # keeps the instruction with the identity it belongs to.
  #
  # A caller that brings its OWN instruction passes it, and the coordination
  # agent (#210) is the one that does: it is an agent and needs a prompt, but
  # not the board's -- it is not there to work a card, and it is not even run
  # in a board's own repository in the sense the drain means. `prompt => ...`
  # is therefore the exception the two identities above make necessary, not a
  # third way for a board agent to be told what to do.
  local $ENV{PROMPT}    = to_octets_for_env(
      defined $opt{prompt} ? $opt{prompt}
    : $role eq 'agent'     ? $self->foundation->_prompt_for( $karr, $ticket )
    :                        '' );

  # The id of the task this run is about, in ticket mode, and empty in every
  # other mode -- localised either way so a run never inherits the previous
  # one's card, and so a template reading it in drain mode gets nothing rather
  # than a stale number. This is the whole machine-readable half of the ticket
  # contract: the prompt above carries the assignment in prose for the agent,
  # $KARR_TASK carries it for a command template that wants the bare id
  # (`myagent --task "$KARR_TASK"`). Deliberately not an argument appended to
  # the command -- how arguments are appended is what `kind: claude-code`
  # settles per agent definition (#188), and an env var is the one thing that
  # works with every command template that exists today, including the
  # synthesized `claude -p "$PROMPT"`.
  local $ENV{KARR_TASK} = defined $ticket ? to_octets_for_env("$ticket") : '';

  # The expansion is the shell's, not ours (#159). Splicing %ENV into the command
  # string here instead meant the shell went on to parse the *values*: a prompt
  # is board content written in Markdown, so its backtick spans and $(...) ran as
  # commands in the board's own directory, and the substitution reached inside
  # single quotes, where sh guarantees a literal — awk '{print $2}' arrived as
  # awk '{print }'. Parameter expansion has neither problem: sh does not rescan
  # an expanded value for substitutions, and it leaves single quotes alone. A
  # template that needs a value the shell cannot see gets it exported above,
  # never spliced.
  #
  # So this logs the template, which is now exactly the string /bin/sh -c is
  # handed. It used to log the substituted result, which after this change is not
  # even computable without reimplementing the shell — and what an operator reads
  # this line for is which command was resolved (--command vs default_command vs
  # .karr vs synthesized claude), not a second copy of the prompt. It also no
  # longer copies whatever an env var held — a wrapper's API key included — into
  # a plaintext .karr.log.
  $self->foundation->_append_log( $repo, 'START '
    . ( $role ne 'agent' ? "role=$role " : '' )
    . ( ref $agent eq 'HASH' && defined $agent->{name} ? "agent=$agent->{name} " : '' )
    . "command=$command" );
  $self->foundation->_say_verbose("exec in $repo: $command");

  if ( $self->foundation->dry_run ) {
    $self->foundation->_append_log( $repo, "DRY-RUN (skipped)" );

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

  # does not end its output with one) still has something to say.
  if ( $render && length $line_buf ) {
    my $text = $self->_render_stream_line( $render, $line_buf );
    if ( length $text ) {
      print {$log_fh} to_octets($text);
      print $text if $stream_terms;
      $shown = substr $text, -1;
    }
  }
  # Rendered text arrives as deltas and the last one rarely ends a line, so
  # without this the shell prompt (and the next log line) lands mid-sentence.
  if ( $render && length $shown && $shown ne "\n" ) {
    print {$log_fh} "\n";
    print "\n" if $stream_terms;
  }

  # Disarm the alarm before reap: a waitpid that takes longer than max_runtime
  # would otherwise be cut short by SIGALRM (no handler anymore — the default
  # action is to die, and Foundation is the parent). $max_runtime == 0 already
  # never armed.
  alarm 0;

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

version 0.600

=head1 DESCRIPTION

L<App::karr::Foundation::Runner> runs a single agent command for
L<App::karr::Foundation>. It forks the command under C</bin/sh -c>, reads its
combined stdout/stderr over a native pipe, and tees each chunk to the
persistent C<.karr.log>, the terminal (when streaming), and an in-memory buffer
the run is classified from, enforcing the per-run C<max_runtime> timeout. A
weak back-reference to the owning foundation supplies shared options and helpers
(C<dry_run>, C<_stream_to_terminal>, C<_prompt_for>, C<_append_log>,
C<_say_verbose>).

That buffer is read twice over, in this order. First for the run's B<own
report>: an agent invoked with C<--output-format json> ends its output with a
JSON object saying whether the run failed, how it ended, how many turns it
took, how long it ran and what it cost. C<_run_result> finds it -- at the tail
of the output, which is the only place a mixture of prose and JSON cannot be
misread -- and C<_result_error> says whether the ending it describes is a
common error and of what kind.

Only where a run left no report does the older text scan run: observable common
errors (rate limit, auth, network, 5xx, ...) matched against the transcript,
where a symptom word counts only next to a failure word on the same line, or
inside a phrase an API really emits, and an HTTP status only where something
adjacent marks it as one. The drain asks that at all only for a run that made
no progress -- see L<App::karr::Foundation>'s "Drain semantics".

The command is a shell template, not a string karr rewrites: C<PROMPT>,
C<KARR_REPO>, C<KARR_ROLE> and C<KARR_TASK> are exported into the child's
environment and C</bin/sh> expands them like any other parameter. A prompt's own
backticks therefore stay text, and C<< awk '{print $2}' >> reaches awk intact.
C<KARR_TASK> holds the id of the task a C<< mode: ticket >> run was given and is
empty in every other mode; the same id is spelled out in the prompt.

Where the agent came from a definition with an invocation contract that asks
for structured live output (C<kind: claude-code>, #188), the tee renders it: the
assistant's own text goes to the terminal and to F<.karr.log> as it arrives,
while the raw stream stays in the classification buffer. That is what lets the
contract ask for a machine-readable format without losing the live output an
interactive run is watched for. A board that names no agent is on the older
path -- the octets the command printed, verbatim, to both sinks.

A C<.karr.log> it cannot open ends the run for that board B<before> the command

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

environment and leaves C<PROMPT> empty, so its own C<karr> writes land in a
different activity log from the agent's and it is never handed the instruction
to go and pick a card. C<< max_runtime => N >> gives it its own budget, because
how long a board's agent may run says nothing about how long whatever the
operator hung on C<on_drained> may take. Nothing else in this method asks who
the caller is: the run is classified by the drain, which simply does not
classify a hook.

The coordination agent (L<App::karr::Foundation::Coordinator>) is the third,
and the one that needed a third option: it B<is> an agent and needs an
instruction, but not a board's -- so it passes C<< prompt => ... >> beside
C<< role => 'coordinator' >> and gets its own text in C<$PROMPT> instead of
the board's or the hook's silence.

=head2 foundation

The owning L<App::karr::Foundation> instance, held C<weak_ref> to avoid a
reference cycle. Supplies the shared options and helpers a run needs
(C<dry_run>, C<_stream_to_terminal>, C<_prompt_for>, C<_append_log>,
C<_say_verbose>) that do not belong to the Runner itself.

=head1 SUPPORT

=head2 Issues

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

=head2 IRC

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

# whatever is left of the deadline when the native one returns -- so a remote
# that is silent rather than absent, the case where both attempts run all the
# way to their limit, still costs the cap once. A native attempt that ended the
# budget by itself leaves nothing to fall back with, and the probe reports the
# deadline instead of forking. With KARR_NO_CLI_FALLBACK there is no second
# attempt to hold anything back for, so the native one gets the whole budget:
# the switch means "native only" here, the way it does everywhere else in this
# class, rather than the "do not ask at all" it used to mean when the CLI was
# the only route.
#
# Nothing here may prompt: this call is not one the user made, so a passphrase
# prompt appearing in the middle of `karr list --json` would be a surprise
# that only the deadline ends. On the CLI side GIT_TERMINAL_PROMPT=0 (set by
# _run_git) covers git's own credential prompts; BatchMode covers ssh's, which
# git never sees. It is appended to the user's own GIT_SSH_COMMAND rather than
# replacing it, so a configured wrapper still runs -- and ssh takes the first
# value it is given for an option, so an explicit BatchMode of theirs still
# wins. The native side runs no ssh binary at all: _default_credentials_cb
# hands libgit2 an agent, a key file with an explicit (empty) passphrase, or
# nothing, and never an interactive credential -- so a passphrase-protected
# key with no agent behind it fails the connection rather than asking anyone.
sub remote_has_board {
    my ( $self, $remote ) = @_;
    $remote //= 'origin';

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

    my $cwd     = $self->repo_root // $self->dir;
    my @cmd     = ( 'git', '-C', $cwd->stringify, @args );
    my $timeout = defined $opt->{timeout} ? $opt->{timeout} : _transport_timeout();
    my %result  = (
        ok => 0, failure => 'start', status => 0,
        out => '', err => '', timeout => $timeout,
    );

    my ( $pid, $timed_out );
    my $started = try {
        local $ENV{GIT_TERMINAL_PROMPT} = 0;   # never hang on an interactive prompt
        my $err_fh = gensym;
        $pid = open3( my $in, my $out_fh, $err_fh, @cmd );
        close $in;

        my %sink = (
            fileno($out_fh) => \$result{out},
            fileno($err_fh) => \$result{err},
        );
        my $select   = IO::Select->new( $out_fh, $err_fh );
        my $deadline = $timeout ? Time::HiRes::time() + $timeout : undef;

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

whatever is left of the deadline, so a silent remote costs the cap once rather
than twice. C<KARR_NO_CLI_FALLBACK> leaves the native attempt alone with the
whole budget instead.

The CLI is still worth a fallback now that the native transport is bounded for
C<ssh://> too (#174, #203), because libgit2 reads no C<~/.ssh/config>: a
C<Host> alias, the C<IdentityFile>, C<User> or C<Port> under it, and a
C<ProxyCommand> exist only for the CLI, and a remote written as C<board:x.git>
is taken by libssh2 for the literal host C<board>.

Neither route can stop and ask: no credential prompt, no ssh passphrase
prompt. A key that needs a passphrase with no agent behind it fails the probe
instead.

L<App::karr::Role::BoardDiscovery/require_local_board> is the caller: a fresh
clone holds no C<refs/karr/*> because C<git clone> does not fetch them, which
is indistinguishable from having no board until someone asks the remote.

=head2 fetch

    my $ok = $git->fetch($remote);   # default 'origin'

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

#   that correctly on its own: a ref the mirror holds and the board no longer
#   does reads as this clone's own deletion rather than as something
#   unfetched, so reconciliation keeps it deleted (measured: with the mirror
#   in place the pull adopts nothing). This check is the cheap half -- it
#   costs no round trip per read -- and it is the only guard left where the
#   mirror is empty but the deletions are unpublished, e.g. a board fetched
#   into refs/karr by hand. A destroy that did land leaves neither tombstones
#   nor a remote board, so the question below refuses it instead.
#
#   The remote actually advertises refs/karr/* (App::karr::Git/remote_has_board
#   -- bounded and never prompting, so an unreachable or silent remote costs
#   seconds, not the command). A remote without a board leaves the refusal
#   exactly as it was: there `karr init` is right.
#
# Only then does it pull, through the ordinary App::karr::Git/pull -- the same
# reconciliation `karr sync --pull` runs, with the same guards, on a board that
# has nothing local to lose. Nothing is pushed: this is a read.
sub _autofetch_board {
    my ($self) = @_;
    return 0 if $ENV{KARR_NO_AUTO_FETCH};

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

# reported before (see expired_claim_report and #177), instead of losing that
# line to a case that answers earlier and says nothing.
#
# Recorded, not printed, and for the same reason as
# App::karr::Role::DependencyCheck: check_claim runs inside
# App::karr::Role::TaskMutation/update_task_guarded's callback, which re-runs
# when another agent gets in first, so a print here would come out once per
# attempt -- and once for an attempt that was then discarded. A slot keyed by
# task id and cleared on entry is replaced by the attempt that wins instead,
# which is also what collapses `karr delete`'s two checks (once outside the
# guard to decide about the prompt, once inside it) into one line.
#
# What is *not* done here: nothing is refused that was not refused before, and
# no new state goes on the card. Taking over an expired claim is the documented
# purpose of claim_timeout, so this makes it audible, not harder. And the
# takeover is not the same event as the holder outliving its own timeout: the
# claimant-matches case returns above without reaching this, so a long-running
# agent never gets warned about itself.
sub check_claim {
    my ($self, $task, $claimant) = @_;
    delete $self->_expired_claims->{ $task->id };

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

}


# The same shape as update_task_guarded, and for the same reason: the claim rule
# is applied to the revision the delete is guarded against, so the two can never
# be about different bytes.
#
# This used to re-read the task and delete by name, because karr had no guarded
# delete to reach for -- App::karr::Git::delete_ref goes through libgit2's
# git_reference_remove(repo, name), which takes no expected-old OID. Re-reading
# closed the window that can stay open for minutes behind a confirmation prompt
# and left the microseconds between the read and the remove, in which a claim
# landing on the card was deleted along with it. App::karr::Git::delete_ref_cas
# closes that one too (#94).
sub delete_task_guarded {
    my ($self, $id, $claimant) = @_;
    my $git = $self->git;
    my $ref = $self->_task_data_ref($id);

    my $task = $git->retry_contended( "task $id", sub {
        my ( $oid, $content ) = $git->read_ref_with_oid($ref);

t/122-foundation-runner-errors.t  view on Meta::CPAN

# The pipe case is the one not exercised here: making pipe(2) fail means
# exhausting the descriptor table, and the soft limit on a normal box is a
# million. It shares its single line of code shape with the two below.

{
    # Stands in for App::karr::Foundation. The Runner holds it weakly, so the
    # caller has to keep it alive -- $foundation below is not a spare variable.
    package FakeFoundation;
    sub new                 { bless {}, shift }
    sub _stream_to_terminal { 0 }
    sub _prompt_for         { '' }
    sub _append_log         { }
    sub _say_verbose        { }
    sub dry_run             { 0 }
}

my $foundation = FakeFoundation->new;
my $runner     = App::karr::Foundation::Runner->new( foundation => $foundation );

subtest 'a refused fork is reported without a karr source location' => sub {
    my $repo = path( tempdir( CLEANUP => 1 ) );

t/148-foundation-runner-child-leak.t  view on Meta::CPAN


{
    # Stands in for App::karr::Foundation with an _append_log that never
    # throws. The real one writes the START line to the very file the subtest
    # below breaks and would die there first -- which is why this window was
    # hard to see, not why it was safe: it is the Runner's own open that has to
    # happen before the fork.
    package FakeFoundation;
    sub new                 { bless {}, shift }
    sub _stream_to_terminal { 0 }
    sub _prompt_for         { '' }
    sub _append_log         { }
    sub _say_verbose        { }
    sub dry_run             { 0 }
}

# Reap or kill whatever a regression leaves behind, so a failing run of this
# file does not also litter the machine. Only touches a pid that is still ours:
# once the library has reaped it, the number may belong to somebody else.
sub cleanup_child {
    my ($pid) = @_;

t/148-foundation-runner-group-kill.t  view on Meta::CPAN

    my $f = App::karr::Foundation->new( _config_data => {} );
    my $start = time;
    my ( $code, $out ) = $f->_run_command(
        $repo, { max_runtime => 1 }, $cmd,
    );
    my $elapsed = time - $start;

    reap_wait($watcher_pid);

    # The runner should report SIGTERM death and have killed the agent
    # promptly -- not waited 30s for the foreground sleep to finish.
    is $code, 143, 'timed-out run reports 128+SIGTERM';
    ok $elapsed < 10, "killed promptly (took ${elapsed}s)"
        or diag "the runner waited the full sleep -- group kill is broken";

    # The bug-shaped check: nothing under /tmp/* should still be running
    # a `sleep 30`. The watcher polled /proc while the run was live and
    # afterwards; if any of those sleeps survived the runner, it is
    # either our reaped watcher (impossible -- it does not run sleep 30)
    # or an orphan that the group kill missed.
    my @still_running;
    opendir my $d, '/proc' or die "opendir /proc: $!";
    while ( my $entry = readdir($d) ) {

t/151-foundation-shell-expansion.t  view on Meta::CPAN

# into the command string in Perl before handing it to /bin/sh -c:
#
#   $command =~ s/\$\{(\w+)\}/$ENV{$1} \/\/ ''/ge;
#   $command =~ s/\$(\w+)/$ENV{$1} \/\/ ''/ge;
#
# PROMPT, KARR_REPO and KARR_ROLE are exported into the child's environment
# anyway, so the shell could expand them itself -- and safely, because it does
# not rescan an expanded value for substitutions. Splicing first meant the shell
# parsed the *values*, with two consequences this file pins:
#
#   1. a prompt is board content written in Markdown, so its backtick spans and
#      $(...) ran as commands, in the board's own directory, and the agent then
#      received an instruction nobody wrote;
#   2. the substitution reached inside single quotes, where sh guarantees a
#      literal, so the output-shaping technique the foundation POD documents
#      (`... | jq -r '...'`) broke silently: awk '{print $2}' arrived at awk as
#      '{print }'.
#
# Both halves need a real fork and a real /bin/sh, so they go through
# _run_command rather than a unit test of a substitution that should not exist.

t/151-foundation-shell-expansion.t  view on Meta::CPAN


use App::karr::Foundation;

# _stream_to_terminal is pinned off: on a TTY the runner tees the agent's output
# to STDOUT, which under prove would be TAP.
my $f = App::karr::Foundation->new(
  _config_data        => {},
  _stream_to_terminal => 0,
);

subtest 'a prompt is data: backticks and $(...) in it are not executed' => sub {
  my $repo = tempdir( CLEANUP => 1 );
  my $tick = $repo->child('RAN-BACKTICK');
  my $dollar = $repo->child('RAN-DOLLAR-PAREN');

  my $prompt = "Run `touch $tick` and \$(touch $dollar), keep it under \$500";

  my ( $code, $out ) = $f->_run_command(
    $repo,
    { prompt => $prompt, max_runtime => 60 },
    'printf "%s" "$PROMPT"',
  );

  is $code, 0, 'command ran';
  ok !$tick->exists,   'the backtick span was not executed';
  ok !$dollar->exists, 'the $(...) was not executed';
  is $out, $prompt,
    'the agent receives the prompt verbatim, $500 and all';
};

subtest q{single quotes protect $2: awk '{print $2}' reaches awk} => sub {
  my $repo = tempdir( CLEANUP => 1 );

  # POSIX awk; if this ever fails to *run*, check that awk exists before
  # reading it as a regression.
  my ( $code, $out ) = $f->_run_command(
    $repo,
    { max_runtime => 60 },

t/151-foundation-shell-expansion.t  view on Meta::CPAN

  is $out, "beta\n",
    'the single-quoted awk program is passed through untouched';
};

subtest 'the variables a template may reference all still expand' => sub {
  my $repo = tempdir( CLEANUP => 1 );
  local $ENV{KARR_TEST_INHERITED} = 'from-parent';

  my ( $code, $out ) = $f->_run_command(
    $repo,
    { prompt => 'PROMPT-VALUE', max_runtime => 60 },
    'printf "%s|%s|%s|%s|[%s]"'
      . ' "$PROMPT" "${KARR_REPO}" "$KARR_ROLE"'
      . ' "$KARR_TEST_INHERITED" "$KARR_TEST_NEVER_SET"',
  );

  is $code, 0, 'command ran';
  is $out, "PROMPT-VALUE|$repo|agent|from-parent|[]",
    'PROMPT, ${VAR} braced form, KARR_ROLE, an inherited var; unset is empty';
};

subtest 'the synthesized claude command hands the prompt over as one argument'
  => sub {
  my $bin_dir = tempdir( CLEANUP => 1 );
  my $bin     = $bin_dir->child('fake-claude');
  $bin->spew_utf8( <<'SH' );
#!/bin/sh
printf 'argc=%s\n' "$#"
for a in "$@"; do printf 'arg=[%s]\n' "$a"; done
SH
  chmod 0755, "$bin" or die "chmod $bin: $!";

  my $repo = tempdir( CLEANUP => 1 );
  $repo->child('globbed.txt')->spew_utf8('x');   # something for * to catch
  my $ran = $repo->child('RAN');

  # Everything a Markdown prompt throws at a shell: a glob, a backtick span,
  # word-splitting whitespace, a metacharacter and an apostrophe.
  my $prompt = "Pick * next; run `touch $ran`, mind the spaces, don't quote";

  my $karr = {
    claude      => 1,
    claude_bin  => "$bin",
    prompt      => $prompt,
    max_runtime => 60,
  };

  my $cmd = $f->_agent_command( $repo, $karr );
  like $cmd, qr/-p "\$PROMPT"/,
    'the synthesized template quotes $PROMPT for the shell';

  my ( $code, $out ) = $f->_run_command( $repo, $karr, $cmd );

  is $code, 0, 'command ran';
  like $out, qr/^argc=6$/m,
    'six arguments: the prompt was neither word-split nor globbed';
  like $out, qr/\Qarg=[$prompt]\E/,
    'the prompt arrives as exactly one argument, verbatim';
  ok !$ran->exists, 'and its backtick span did not run';
};

subtest 'the START log line records the template handed to /bin/sh' => sub {
  my $repo = tempdir( CLEANUP => 1 );
  my $cmd  = 'printf "%s" "$PROMPT" >/dev/null';

  $f->_run_command( $repo, { prompt => 'PROMPT-VALUE', max_runtime => 60 }, $cmd );

  my ($start) = grep { /START command=/ }
    split /\n/, $repo->child('.karr.log')->slurp_utf8;

  like $start, qr/\QSTART command=$cmd\E/,
    'the template is logged verbatim';
  unlike $start, qr/PROMPT-VALUE/,
    'not the substituted result: env values stay out of .karr.log';
};

t/165-max-runtime-zero-drain.t  view on Meta::CPAN

  my $f = App::karr::Foundation->new;
  local $ENV{KARR_FAKE_MODE} = 'progress';

  # Override the agent with a sleep so each iteration takes longer than the
  # budget and the wall-clock cap is what stops the drain — not the
  # iteration cap, not the actionable check.
  my $start = time;
  $f->_drain_repo( $repo,
    { command => 'sleep 30', max_runtime => 1, max_iterations => 50 } );
  ok time - $start < 25,
    'wall-clock budget ends the drain promptly (not 50 * sleep)';
};

done_testing;

t/167-env-octet-crossing.t  view on Meta::CPAN

use warnings;
use Test::More;
use Encode qw( encode_utf8 decode FB_CROAK LEAVE_SRC );
use Path::Tiny qw( path );

use App::karr::Encoding qw(
    to_octets to_octets_for_env
    from_octets from_octets_from_env
);

# Ticket #167: %ENV is a byte boundary karr did not own. A non-ASCII prompt
# assigned at Foundation/Runner.pm:63 emitted "Wide character in setenv" on
# foundation's stderr — the bytes the child received were correct UTF-8
# either way, so this is a warning, not a data bug. The fix routes both
# sides of the crossing through App::karr::Encoding: to_octets_for_env on
# the way out, from_octets_from_env on the way back in.

my $NON_ASCII = "Caf\x{e9} \x{2014} na\x{ef}ve prompt \x{1f914}";

subtest 'to_octets_for_env is the character-to-octet edge for %ENV' => sub {
    # The function is documented as the ENV-crossing edge, not a new codec.
    # Behavioural equivalence to to_octets is the property — name carries the
    # intent at the call site.
    is( to_octets_for_env($NON_ASCII), to_octets($NON_ASCII),
        'to_octets_for_env and to_octets agree on a non-ASCII input' );
    is( to_octets_for_encoded($NON_ASCII), encode_utf8($NON_ASCII),
        'to_octets_for_env returns the canonical UTF-8 octets' );
    is( to_octets_for_env(undef), undef, 'undef passes through' );

t/177-expired-claim-takeover.t  view on Meta::CPAN

        is( $r->{exit}, 0, 'the archive still succeeds' ) or diag $r->{stderr};
        like( $r->{stderr}, _trace_re( 2, $HOLDER ),
            'a card archived out from under an expired claim says whose it was' );
    };

    subtest 'delete, which checks the claim twice' => sub {
        my $r = _run_karr( $repo, 'delete', '3', '--yes' );
        is( $r->{exit}, 0, 'the delete still succeeds' ) or diag $r->{stderr};

        # Delete applies the rule once outside the guard (to decide whether to
        # prompt) and again inside delete_task_guarded, against the revision it
        # removes. Two checks, one card, one line: the record is keyed by task
        # id and replaced, not appended to.
        my @lines = ( $r->{stderr} =~ /(overriding the expired claim)/g );
        is( scalar @lines, 1,
            'the two check_claim calls on the delete path report once, not twice' )
          or diag $r->{stderr};
        like( $r->{stderr}, _trace_re( 3, $HOLDER ), '...and it names the holder' );
    };

    subtest 'the holder continuing under its own name is not reported' => sub {

t/185-foundation-ticket-mode.t  view on Meta::CPAN

  return $store;
}

sub task_by_id {
  my ( $repo, $id ) = @_;
  my $git = App::karr::Git->new( dir => "$repo" );
  return App::karr::BoardStore->new( git => $git )->find_task( $id );
}

# One line per invocation holding the id the run was given, so a test can prove
# both which card was named and how many agent runs happened -- and the prompt
# of the last run, which is the other half of how the assignment travels.
sub write_fake_agent {
  my ( $dir ) = @_;
  my $lib    = path('lib')->absolute->stringify;
  my $script = path($dir)->child('fake-agent.pl');
  $script->spew_utf8(<<'PERL');
use strict;
use warnings;
my $repo = $ENV{KARR_REPO} or die "no KARR_REPO\n";
my $mode = $ENV{KARR_FAKE_MODE} // 'assigned';
path_append( "$repo/runs.log", ( $ENV{KARR_TASK} // '' ) . "\n" );
path_write( "$repo/prompt.txt", $ENV{PROMPT} // '' );
exit 0 if $mode eq 'idle';

require App::karr::Git;
require App::karr::BoardStore;
require App::karr::ActivityLog;
my $store = App::karr::BoardStore->new(
  git => App::karr::Git->new( dir => $repo ) );

my $target;
if ( $mode eq 'assigned' ) {

t/185-foundation-ticket-mode.t  view on Meta::CPAN

    'an expired claim is taken over, and critical outranks high';
};

# ---------------------------------------------------------------------------
# Unit: how the assignment reaches the agent
# ---------------------------------------------------------------------------

subtest 'the ticket travels in $PROMPT and $KARR_TASK' => sub {
  my $f = App::karr::Foundation->new( _config_data => {} );

  my $plain = $f->_prompt_for( {} );
  unlike $plain, qr/task for this run/, 'no assignment without a ticket';

  my $assigned = $f->_prompt_for( {}, 42 );
  like $assigned, qr/task for this run is #42/, 'the id is in the prompt';
  unlike $assigned, qr/pick the next actionable task/,
    'the built-in default no longer tells the agent to choose its own work';

  my $custom = $f->_prompt_for( { prompt => 'CUSTOM' }, 42 );
  like $custom, qr/\ACUSTOM\n\nThe task for this run is #42/,
    'a configured prompt is kept, with the assignment appended after it';

  my $rdir = tempdir( CLEANUP => 1 );
  my ( undef, $out ) = $f->_run_command( $rdir, {}, 'printf "[%s]" "$KARR_TASK"' );
  is $out, '[]', 'KARR_TASK is empty outside ticket mode';
  my ( undef, $out2 ) = $f->_run_command( $rdir, {}, 'printf "[%s]" "$KARR_TASK"', 7 );
  is $out2, '[7]', 'and carries the id in it';
};

# ---------------------------------------------------------------------------
# Integration: one run, one ticket, and back

t/185-foundation-ticket-mode.t  view on Meta::CPAN

  is $res->{outcome}, 'progress', 'the ticket moved';

  my @runs = runs_of( $repo );
  is scalar @runs, 1, 'exactly one agent run, board still actionable or not';
  is $runs[0], '2', 'the agent was handed the id foundation picked';

  is task_by_id( $repo, 2 )->status, 'done', 'the assigned card is done';
  is task_by_id( $repo, 1 )->status, 'todo', 'the rest of the board is untouched';
  is task_by_id( $repo, 3 )->status, 'todo', 'no drain happened';

  like path( $repo )->child('prompt.txt')->slurp_utf8, qr/task for this run is #2/,
    'the prompt the agent saw named the card';
};

# ---------------------------------------------------------------------------
# Integration: nothing to assign is not a reason to start an agent
# ---------------------------------------------------------------------------

subtest 'no assignable card means no agent run at all' => sub {
  my $repo = make_git_repo();
  seed_board( $repo, { status => 'done' }, { status => 'todo', blocked => 'why' } );
  my $agent = write_fake_agent( $repo );

t/193-foundation-on-drained.t  view on Meta::CPAN

sub write_fake_hook {
  my ( $dir ) = @_;
  my $lib    = path('lib')->absolute->stringify;
  my $script = path($dir)->child('fake-hook.pl');
  $script->spew_utf8(<<'PERL');
use strict;
use warnings;
use Cwd ();
my $repo = $ENV{KARR_REPO} or die "no KARR_REPO\n";
open my $fh, '>>', "$repo/hook-runs.log" or die $!;
printf {$fh} "role=%s task=[%s] prompt=[%s] cwd=%s\n",
  ( $ENV{KARR_ROLE} // '<unset>' ), ( $ENV{KARR_TASK} // '<unset>' ),
  ( $ENV{PROMPT} // '<unset>' ), Cwd::getcwd();
close $fh;

print $ENV{KARR_FAKE_HOOK_OUTPUT} . "\n" if $ENV{KARR_FAKE_HOOK_OUTPUT};

if ( $ENV{KARR_FAKE_HOOK_FILES} ) {
  require App::karr::Git;
  require App::karr::BoardStore;
  require App::karr::Task;

t/193-foundation-on-drained.t  view on Meta::CPAN

# ---------------------------------------------------------------------------
# What the hook is told
# ---------------------------------------------------------------------------

subtest 'the hook is told where it is and nothing else' => sub {
  my $repo  = make_git_repo();
  seed_board( $repo, 'tidy the parser' );
  my $agent = write_fake_agent( $repo );
  my $hook  = write_fake_hook( $repo );
  path( $repo )->child('.karr')->spew_utf8(
    "command: $agent\nmax_runtime: 60\nprompt: DO THE AGENT THING\n"
    . "on_drained: $hook\n" );

  my $f = App::karr::Foundation->new( _config_data => {} );
  $f->_process_repo( path( $repo ) );

  my ( $line ) = hook_runs( $repo );
  ok defined $line, 'the hook ran' or return;
  like $line, qr/\brole=hook\b/,
    'KARR_ROLE says hook, so a karr write of its own is not filed as the '
    . "agent's engagement with a card";
  like $line, qr/\btask=\[\]/, 'no ticket: the hook was given no assignment';
  like $line, qr/\bprompt=\[\]/,
    'and no prompt -- the prompt is the agent instruction, and the hook is '
    . 'not an agent';
  like $line, qr{\bcwd=\Q$repo\E}, 'it runs in the board it drained';
};

# ---------------------------------------------------------------------------
# What the hook is not
# ---------------------------------------------------------------------------

subtest 'a hook that fails is not a failing agent' => sub {
  my $repo   = make_git_repo();

t/210-foundation-coordinator.t  view on Meta::CPAN

  binmode STDOUT, ':encoding(UTF-8)';
  my ( $ret, $err );
  eval { $ret = $code->(); 1 } or $err = $@;
  open STDOUT, '>&', $save or die "restore stdout: $!";
  close $save;
  die $err if defined $err;
  return ( $file->slurp_utf8, $ret );
}

# The fake coordination agent. It records what it was given -- its argv (so
# the kind: claude-code contract is visible), the prompt the shell expanded
# for it, its role and its working directory -- and can be told to fail or to
# leave a result object behind.
my $OUT;    # where the fake records, set per subtest
sub write_coordinator {
  my ( $dir ) = @_;
  my $script = path($dir)->child('fake-coordinator.pl');
  $script->spew_utf8(<<'PERL');
use strict;
use warnings;
use Path::Tiny qw( path );
my $out = path( $ENV{FAKE_OUT} or die "no FAKE_OUT\n" );
$out->mkpath unless $out->is_dir;
$out->child('runs')->append_utf8("run\n");
my ( $p ) = grep { $ARGV[$_] eq '-p' } 0 .. $#ARGV;
$out->child('prompt')->spew_utf8( defined $p ? ( $ARGV[ $p + 1 ] // '' ) : '' );
$out->child('argv')->spew_utf8( join "\n", @ARGV );
$out->child('env')->spew_utf8( join "\n",
  'role=' . ( $ENV{KARR_ROLE} // '' ),
  'task=' . ( $ENV{KARR_TASK} // '' ),
  'cwd='  . path('.')->realpath );
print "$ENV{FAKE_RESULT}\n" if defined $ENV{FAKE_RESULT} && length $ENV{FAKE_RESULT};
exit( $ENV{FAKE_EXIT} // 0 );
PERL
  return qq{$^X -I"$LIB" "$script"};
}

t/210-foundation-coordinator.t  view on Meta::CPAN

  $OUT = $dir;
  $ENV{FAKE_OUT} = "$dir";
  return $dir;
}

sub coordinator_runs {
  my $f = $OUT->child('runs');
  return $f->exists ? ( grep { length } split /\n/, $f->slurp_utf8 ) : ();
}

sub coordinator_prompt {
  my $f = $OUT->child('prompt');
  return $f->exists ? $f->slurp_utf8 : '';
}

sub log_of {
  my $f = path( $_[0] )->child('.karr.log');
  return $f->exists ? $f->slurp_utf8 : '';
}

sub coordinator_env {
  my $f = $OUT->child('env');

t/210-foundation-coordinator.t  view on Meta::CPAN

    },
  );

  my ( $printed ) = capture( sub { foundation($cfg)->run } );

  is scalar( coordinator_runs() ), 1,
    'THREE boards nobody has routed are ONE call: a tick that met five '
    . 'deviations has learned one thing, and five calls would pay five times '
    . 'to hear it';

  my $prompt = coordinator_prompt();
  like $prompt, qr/\Q$_\E: no assignment names this repository/, "prompt names $_"
    for @repo;
  like $prompt, qr/Never hand it a release/,
    'the operator\'s own prose reaches it -- that prose IS the routing '
    . 'criterion, and karr never parses it';
  like $prompt, qr/cheap and fast/, 'so does each agent\'s description';
  like $prompt, qr/\Qminimax\E\s+kind: shell\s+ok/, 'with what is known about it now';
  like $prompt, qr/assignment\s+\Q@{[ path($cfg)->sibling('assignment.yml') ]}\E/,
    'and the path it is supposed to write';
  like $prompt, qr/never names an agent|Name an agent in a chain step/i,
    'the boundary that keeps routing out of the shared chain is stated';

  my $env = coordinator_env();
  like $env, qr/role=coordinator/,
    'it runs under its own role, so its karr writes are not an agent\'s '
    . 'engagement with a card';
  like $env, qr/task=$/m, 'and it is given no ticket: it is not working a card';
  like $env, qr{cwd=\Q@{[ path($hub)->realpath ]}\E},
    'in the hub, where the chain and the questions it may write live';

t/210-foundation-coordinator.t  view on Meta::CPAN

      precheck => 'board_actionable == yes' },
    { id => 3, kind => 'plan', note => 'and then?' },
  ] );

  my ( $printed, $exit ) = capture( sub { foundation($cfg)->run('chain') } );
  is $exit, 0, 'the tick finished';
  ok !$marker->exists, 'the stale step did not run';

  is scalar( coordinator_runs() ), 1,
    'two plan steps and one stale step are ONE call at the end of the tick';
  my $prompt = coordinator_prompt();
  like $prompt, qr/step 1: kind: plan is not executed here/, 'the plan step is named';
  like $prompt, qr/step 3: kind: plan is not executed here/, 'both of them';
  like $prompt, qr/step 2: .*precheck/,
    'and the stale step, with the precheck that stopped holding';
  like $printed, qr/the coordination agent is called at the end of this tick/,
    'the tick says a planner is wanted AND that one exists to be called';
};

subtest 'an overdue escalate_to_ai question is one of the deviations' => sub {
  my $out = fake_out();
  my $hub = make_repo();
  my $cfg = write_config(
    hub    => "$hub",

t/210-foundation-coordinator.t  view on Meta::CPAN


  chain_store($hub)->write_chain( [ { id => 1, kind => 'question' } ] );
  my $qid = App::karr::Foundation::Questions->new(
    git => App::karr::Git->new( dir => "$hub" ) )->ask(
      question => 'which registry?', policy => 'escalate_to_ai',
      deadline => '2000-01-01T00:00:00Z', step => 1 );

  my ( $printed ) = capture( sub { foundation($cfg)->run('chain') } );

  is scalar( coordinator_runs() ), 1, 'the policy that names the agent calls it';
  like coordinator_prompt(), qr/step 1: escalate_to_ai on question #\Q$qid\E/,
    'naming the question, not merely the step';
  is chain_store($hub)->step(1)->{state}, 'pending',
    'and the step is left exactly as the planner left it: the question is '
    . 'still open, and nothing here answered it on the agent\'s behalf';
};

# ---------------------------------------------------------------------------
# 4. An agent like any other
# ---------------------------------------------------------------------------

t/213-foundation-plan-cli.t  view on Meta::CPAN

use App::karr::Git;
use App::karr::Encoding qw( yaml_load );
use App::karr::Foundation;
use App::karr::Foundation::ChainStore;

# Ticket #213: karr-foundation gets a command that writes a chain.
#
# Before it, App::karr::Foundation::ChainStore->write_chain was the only way in
# -- Perl API -- so the one writer that is not a person, the coordination
# agent (#210), was handed a `perl -MApp::karr::Foundation::ChainStore -e ...`
# one-liner in its prompt and asked to type it out. That was the single place
# where karr gave an agent Perl instead of a command, and it meant a rename
# inside that class broke a prompt rather than a call: silently, and only on
# the tick where a plan was wanted.
#
# What is pinned here, and why each is a decision rather than an accident:
#
#   1. The chain arrives as a DOCUMENT on stdin (or --input), not as options.
#      A chain is a DAG and a DAG is nested; the writer that matters most
#      already produces structure. YAML, and JSON through the same parser.
#   2. It REPLACES the chain, it does not append. Only steps whose chain id
#      matches the header are ever ready, so an append would be a new chain
#      over the old steps and the new ones -- a merge with rules of its own.
#   3. Validation is the store's, and nothing is written when it fails: a
#      document karr will not take leaves the chain in the hub exactly as it
#      was, and the writer gets a sentence rather than a Perl error.
#   4. The exit-code contract holds (ADR 0002): a bad invocation is 2, a bad
#      document is 1 -- the document is data, not argv.
#   5. The coordination agent's prompt names the command and no longer carries
#      Perl for it to type.
#
# No agent is ever started: the only invocations here write or check a chain,
# and the prompt is built in-process without dispatching it.

my $ROOT = abs_path('.');

sub run_foundation {
    my ( %arg ) = @_;
    my $old = getcwd();
    my $cwd = $arg{cwd} // $ROOT;
    chdir $cwd or die "chdir $cwd: $!";
    my $errfh = gensym;
    my $pid = open3( my $in, my $outfh, $errfh,

t/213-foundation-plan-cli.t  view on Meta::CPAN

agents:
  planner:
    command: /bin/true
    role: coordinator
CONFIG

    my $foundation = App::karr::Foundation->new( config => $cfg );
    my $coordinator = $foundation->_coordinator;
    ok( $coordinator->configured, 'the fleet marks a coordination agent' );
    $coordinator->want( step => 4, reason => 'kind: plan is not executed here' );
    my $prompt = $coordinator->prompt( $coordinator->wanted );

    unlike( $prompt, qr/perl -M/,
        'the prompt no longer carries a perl one-liner (#213)' );
    unlike( $prompt, qr/write_chain/,
        'nor the name of the storage method it called' );
    like( $prompt, qr/karr-foundation --config '\Q$cfg\E' plan <</,
        'it is told the command, with the config the fleet was started with' );
    like( $prompt, qr/karr-foundation --config '\Q$cfg\E' ask /,
        'and the mailbox command carries it for the same reason' );
    like( $prompt, qr/REPLACES the chain/,
        'and that writing one replaces what is there' );

    # The prompt is worth nothing if the shape in it is not one karr takes, so
    # the document the agent is shown is run through the parser it will meet.
    my ( $document ) = $prompt =~ /plan <<'CHAIN'\n(.*?)\n\s*CHAIN\n/s;
    ok( $document, 'the prompt shows a whole document' );
    $document =~ s/^ {5}//mg;
    my ( $steps, %header ) = store($hub)->parse_chain_document(
        yaml_load($document) );
    is( scalar @$steps, 2, 'which parses as a chain document' );
    my $validated = store($hub)->validate_chain($steps);
    is_deeply( [ map { $_->{id} } @$validated ], [ '1', '2' ],
        'and validates as a chain' );
};

done_testing();

t/236-delete-dependent-warning.t  view on Meta::CPAN

#
# The channel is the one App::karr::Role::DependencyCheck already argued for:
# the human copy on STDERR so STDOUT stays parseable, --json carrying the same
# sentence in the result object because a JSON consumer never reads STDERR, and
# --quiet silencing the STDERR copy only.

# In-process runner (t/lib/TestKarr.pm): same ($cwd, @argv) signature and
# { exit, stdout, stderr } return as the open3 helper this file used to carry,
# dispatched through the shared App::karr::Dispatch path. This file's own
# convention -- a leading SCALAR ref in @argv standing for the answer typed at
# `karr delete`'s confirmation prompt -- still works, now routed through
# run_karr_stdin. KARR_TEST_SUBPROC=1 restores the old open3 path.
sub _run_karr {
    my ( $cwd, @argv ) = @_;
    my $stdin_text = ref $argv[0] eq 'SCALAR' ? ${ shift @argv } : undef;
    return defined $stdin_text
        ? run_karr_stdin( $cwd, $stdin_text, @argv )
        : run_karr( $cwd, @argv );
}

# A fresh isolated temp repo per subtest, never the developer's real board.

t/241-delete-prompt-flush.t  view on Meta::CPAN

# combined stream (t/249-combined-output-order.t). So the question now reaches
# the pipe with or without Delete's own STDERR->flush, and this file no longer
# fails if that line is deleted -- it fails if the autoflush and the flush both
# go. What it pins is unchanged and is the part a user notices: the question is
# out on the wire before karr blocks on the answer, whoever put it there.

my $ROOT = abs_path('.');
my $BIN  = "$ROOT/bin/karr";

# How long to wait for the question. Only a run where nothing pushed the
# question out pays it -- when something did, the prompt arrives as soon as karr
# gets there.
my $DEADLINE = 20;

# A fresh isolated temp repo, never the developer's real board.
sub _board {
    my $repo = tempdir( CLEANUP => 1 );
    system( 'git', 'init', '-q', $repo ) == 0 or die 'git init';
    system( 'git', '-C', $repo, 'config', 'user.email', 'test@example.com' ) == 0
        or die 'git config';
    system( 'git', '-C', $repo, 'config', 'user.name', 'Test User' ) == 0

t/241-delete-prompt-flush.t  view on Meta::CPAN

    # Deliberately nothing written to $in yet, and $in stays open: karr is left
    # blocking on a read that will not complete, which is the moment the
    # operator is looking at a cursor.
    #
    # Watched on stderr, not stdout: ticket #248 moved the question to the
    # channel a question belongs on, so that stdout carries only the outcome and
    # stays decodable under --json. The property this subtest pins is unchanged
    # by that, and unchanged again by #249's autoflush: the question is out on
    # the wire before karr blocks, whether Delete's own flush put it there or
    # the autoflush App::karr::Encoding sets beside the :encoding(UTF-8) layer.
    my $prompt   = '';
    my $deadline = time + $DEADLINE;
    while ( time < $deadline ) {
        my $rin = '';
        vec( $rin, fileno($err), 1 ) = 1;
        my $ready = select( my $rout = $rin, undef, undef, 1 );
        next unless defined $ready && $ready > 0;
        my $chunk = '';
        my $read  = sysread( $err, $chunk, 4096 );
        last unless $read;    # child died or closed stderr
        $prompt .= $chunk;
        last if $prompt =~ /\[y\/N\]/;
    }

    like( $prompt, qr/Delete task 1: Doomed\? \[y\/N\] /,
        'the whole question is out on the wire while karr is still waiting' );

    # Answer it, so the child always finishes and this file never hangs -- also
    # on a karr that leaves the question in a buffer, where the loop above ran
    # out of time.
    print {$in} "n\n";
    close $in;

    my $stdout = do { local $/; <$out> };
    my $rest   = do { local $/; <$err> };
    waitpid( $pid, 0 );
    my $exit = $? >> 8;

    $stdout = '' unless defined $stdout;
    $rest   = '' unless defined $rest;

    is( $exit, 0, 'answering no is an answer, not a failure' )
        or diag $prompt . $rest;
    like( $prompt . $rest . $stdout, qr/Skipped task 1: Doomed/,
        'and the exchange still reads as one conversation from end to end' );
    like( $stdout, qr/Skipped task 1: Doomed/,
        'with the outcome on stdout, where a caller reads results (#248)' );
    unlike( $stdout, qr/\[y\/N\]/,
        'and the question on stderr, where a caller reads dialogue (#248)' );
};

done_testing;

t/242-delete-cross-board-warning.t  view on Meta::CPAN

#
# Channel is #236's: STDERR for the human copy, --quiet silences it, --json
# carries the same sentences in the result object. The key is its own,
# `cross_board_warnings`, because a consumer that can act on a dangling
# dependency on this board cannot act on a card in another repository.

# In-process runner (t/lib/TestKarr.pm): same ($cwd, @argv) signature and
# { exit, stdout, stderr } return as the open3 helper this file used to carry,
# dispatched through the shared App::karr::Dispatch path. This file's own
# convention -- a leading SCALAR ref in @argv standing for the answer typed at
# `karr delete`'s confirmation prompt -- still works, now routed through
# run_karr_stdin. KARR_TEST_SUBPROC=1 restores the old open3 path.
sub _run_karr {
    my ( $cwd, @argv ) = @_;
    my $stdin_text = ref $argv[0] eq 'SCALAR' ? ${ shift @argv } : undef;
    return defined $stdin_text
        ? run_karr_stdin( $cwd, $stdin_text, @argv )
        : run_karr( $cwd, @argv );
}

# A board of a given *name*, because a cross-board reference is a board name

t/242-delete-cross-board-warning.t  view on Meta::CPAN

        'the warning was given before the confirmation was answered' );
    like( $r->{stdout}, qr/Skipped task 1/, 'and the operator could act on it' );
    ok( _task( $here, 1 ), 'so the card is still there' );
};

subtest 'the kept card carries them under --json too' => sub {
    my $here = _board('boardA');
    _create( $here, 'Fix the API', '--escalated-from', 'boardB#5' );

    my $r = _run_karr( $here, \"n\n", 'delete', '1', '--json' );
    # The confirmation prompt is printed to STDOUT whatever --json says, so the
    # object is decoded from where it starts. That is a separate defect of the
    # prompt (the neighbour of #241, which fixed its flushing and not its
    # channel), not of the warning under test, and this test declines to pin it
    # either way.
    my ($json) = $r->{stdout} =~ /(\{.*\})/s;
    my $data = eval { decode_json( $json // '' ) };
    ok( $data, 'STDOUT carries the result object' )
        or diag "stdout was: $r->{stdout}";
    ok( !$data->{deleted},
        'deleted:false says the delete the warning named did not happen' );
    like( $data->{cross_board_warnings}[0], qr/was escalated from boardB#5/,
        'and the warning is beside it, which under --json is its only channel' );



( run in 0.998 second using v1.01-cache-2.11-cpan-aadc1410aed )