App-karr

 view release on metacpan or  search on metacpan

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

---
name: karr-ref-worker
description: "App::karr Git/ref storage worker — Git::Native and CLI fallback, refs-backed BoardStore persistence, CAS, locks, sync lifecycle, encoding boundaries, backup/restore, materialize/import, repair, and helper refs."
model: inherit
tools: Read, Edit, Write, Bash, Glob, Grep
briefing:
  skills:
    - getty-perl-core
    - getty-perl-moo
---

You are the Git/ref persistence worker for **App::karr**. You own the path from an in-memory
board operation to durable `refs/karr/*` state and remote synchronization. Apply the loaded
conventions silently.

## Territory

- `lib/App/karr/Git.pm`, `BoardStore.pm`, `Lock.pm`, `SyncGuard.pm`, and `Encoding.pm`
- `Role/BoardDiscovery.pm`, `BoardAccess.pm`, and `SyncLifecycle.pm`
- storage/transport commands: `init`, `sync`, `materialize`, `import`, `repair`, `backup`,
  `restore`, `destroy`, `get-refs`, `set-refs`, and `unlock`
- skill installation plumbing in `Cmd/Skill.pm`, the share-file lookup in `Cmd/Init.pm`,
  and `share/claude-skill.md`

Preserve these invariants: canonical board state is refs-first; the file tree is only a
materialized view; concurrent writes use the established CAS/lock path; native Git handles
local operations while the CLI fallback exists for transport compatibility; characters stay
inside and octets cross only through `App::karr::Encoding`.

## Boundaries

.claude/rules/karr-rules.md  view on Meta::CPAN


- **You can spawn subagents** (orchestrating main agent): Do NOT touch behavior-relevant karr
  code yourself — delegate to the domain worker below. Your lane: coordinate, inspect, plan, review diffs,
  run tests, manage git, edit non-behavioral docs. Why: only the `karr-*` agents get their skills
  force-loaded via `briefing.skills`; you get no briefing and would touch internals with too
  little context. Specialist lanes:

  | Task | Agent |
  |---|---|
  | Task/config semantics, lifecycle, claims, dependencies, activity log, ordinary board commands, filtering, rendering, `context`, `metrics` | `karr-board-worker` |
  | Git transport, refs-backed persistence, CAS, locks, sync, encoding, `init`/`sync`/`materialize`/`import`/`repair`/`backup`/`restore`/`destroy`/`unlock`, share-file plumbing | `karr-ref-worker` |
  | karr-foundation: multi-board discovery, overview, agent command resolution, drain loops, `.karr.lock`/`.karr.state`, cooldown, stall detection, auto-blocking, `disable`/`enable` | `karr-foundation-worker` |
  | Behavior-relevant code that spans those domains, or none of them cleanly | `karr-worker` (generalist fallback) |
  | Write/extend tests under `t/` | `karr-test-writer` |
  | Pre-release audit (Changes, cpanfile, dist.ini, version) | `karr-release-checker` |
  | POD (`=attr`/`=method`, ABSTRACT) | `karr-pod-writer` |

  Pick the narrowest domain worker that covers the task — they brief on one domain instead of
  the whole distribution, and each one names the other two in its own boundaries section, so a
  task that turns out to belong elsewhere gets handed back rather than solved from the wrong
  context. `karr-worker` stays for work that genuinely crosses the seams.

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

`created` up to that `created` — karr wrote `started` as a bare date until
ticket #68, which reads as midnight and so lands before a card created later
the same day. A clamped card then asserts zero queue time and no longer
records that its stamp was ever day-granular, so the dry run tells you how
many cards that is before you apply it. It reports, but does not touch,
`completed` stamps with the same day-granular problem.

### Backup and restore

```bash
karr backup > karr-backup.yml
karr restore --yes < karr-backup.yml
```

`restore` is destructive and replaces the entire `refs/karr/*` namespace.

### Destroy

```bash
karr destroy --yes
```

Deletes the entire `refs/karr/*` namespace from the repository and prunes the
remote board state too when a remote is configured. Prefer taking a
`karr backup` first.

### Helper refs

```bash
karr set-refs superpowers/spec/1234.md draft ready
karr set-refs superpowers/spec/1234.md < design.md    # multi-line payload
karr get-refs superpowers/spec/1234.md
```

Stores and retrieves helper payloads in Git refs outside protected namespaces

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

3. **What's on the board?** → `karr board` or `karr list`
4. **Starting work?** → `karr pick --claim NAME --move in-progress`
5. **Done with task, hand to review?** → `karr handoff ID --claim NAME --note "reason"`
6. **Done with task, close it?** → `karr edit ID --release && karr move ID done`
7. **Blocked?** → `karr edit ID --block "reason"`
8. **Need details?** → `karr show ID`
9. **Soft-delete?** → `karr archive ID`
10. **Board snapshot for agent context?** → `karr context --write-to AGENTS.md`
11. **Check/change config?** → `karr config` / `karr config set KEY VALUE`
12. **Install agent skills?** → `karr skill install`
13. **Need a full board snapshot?** → `karr backup` / `karr restore --yes`
14. **Need shared non-task workflow data?** → `karr set-refs` / `karr get-refs`
15. **Board should never be drained by an automation host?** → `karr disable --reason "why"`
16. **Need to remove the board completely?** → `karr destroy --yes`
17. **Overview of every board under a directory?** → `karr dashboard`

## Multi-agent workflow

```bash
# 1. Generate agent name and pick task
NAME=$(karr agent-name)

CLAUDE.md  view on Meta::CPAN


Engineering discipline, the delegation lane, board coordination, and the release policy live in
`.claude/rules/karr-rules.md` — auto-loaded by Claude Code at launch. Don't restate them here.

**Delegate behavior-relevant code instead of editing it yourself** (the rule and rationale are in
the rules file). Agents in this repo (skills force-loaded via `briefing.skills`):

| Task | Agent |
|---|---|
| Task/config semantics, lifecycle, claims, dependencies (`needs`/`CrossBoard`), activity log, ordinary board commands, filtering, rendering | `karr-board-worker` |
| Git transport, refs-backed storage, CAS, locks, sync, encoding, materialize/import/backup/restore | `karr-ref-worker` |
| karr-foundation: discovery, drain loops, per-repo lock/state, cooldown, auto-blocking, `disable`/`enable` | `karr-foundation-worker` |
| Behavior-relevant code spanning those domains, or none of them cleanly | `karr-worker` (generalist fallback) |
| Write/extend tests under `t/` | `karr-test-writer` |
| Pre-release audit (Changes, cpanfile, dist.ini, version) | `karr-release-checker` |
| POD (`=attr`/`=method`, `# ABSTRACT`) | `karr-pod-writer` |

Take the narrowest domain worker that fits; each names the other two in its boundaries section
and hands a misrouted task back rather than solving it from the wrong context.

**Dogfood:** karr tracks its own work on its own board (`refs/karr/*`). Use `karr list --compact`

CLAUDE.md  view on Meta::CPAN

| `metrics` | implemented | `metrics` |
| `log` | implemented | `log` |
| `config` | implemented | `config` |
| `context` | implemented | `context` |
| `agent-name` | implemented | `agent-name` |
| `skill` | implemented | `skill` |
| `materialize` | implemented | — (refs→files bridge) |
| `import` | implemented | — (files→refs bridge) |
| `repair` | implemented | — (migrates a 0.402-or-earlier board off double-encoded UTF-8) |
| `sync` | implemented | — (explicit refs pull/push) |
| `backup` | implemented | — (board snapshot to YAML) |
| `restore` | implemented | — (snapshot→refs, destructive) |
| `destroy` | implemented | — (remove `refs/karr/*` incl. remote) |
| `set-refs` / `get-refs` | implemented | — (helper refs outside the board) |
| `disable` / `enable` | implemented | — (board-level opt-out from karr-foundation runs) |

## Key design decisions

- **MooX::Cmd** for subcommand dispatch (not App::Cmd — lighter, Moo-native)
- **MooX::Options** for CLI option parsing
- **YAML::XS** for frontmatter (fast, correct YAML parsing)

Changes  view on Meta::CPAN

      unchanged and remains the one command that may stamp an existing board,
      because it is the one that rewrites every ref.
    - "No karr board found. Run 'karr init' to create one." no longer speaks
      for two different repositories (ticket #133). Write commands raised it
      whenever `refs/karr/config` was absent, including on a repository whose
      tasks, counter and log were all there — so a board missing exactly one
      ref reported itself as never having existed. An agent hit that on a
      repository holding 21 tickets, believed them gone, and ran `karr init`,
      which at the time also broke how they were read (#132 above). A
      repository with nothing under `refs/karr/` still gets the old sentence,
      the one `backup`, `destroy`, `materialize` and `repair` raise for the
      same state. A half-board now gets its own: it is named as one, the number
      of task refs at stake is stated, and `karr init` is described as
      completing the board while keeping what is already there. `karr init`
      likewise reports which of the two things it did.
    - `karr config get statuses` and `karr config get classes` are readable
      again (ticket #130). Both lists allow an entry to be either a bare name or
      a mapping — `{ name: in-progress, require_claim: true }`,
      `{ name: expedite, wip_limit: 1 }` — and the renderer joined the raw list,
      so every mapping printed as `HASH(0x558580cd1688)`. The default board hits
      it twice in `statuses` and, because all four of its classes are mappings,

Changes  view on Meta::CPAN

    - Finished the sweep that stopped karr's own source locations reaching the
      user (ticket #77). `croak` appends " at Some/Module.pm line 42." even to a
      message that already ends in a newline, so anyone who ran `karr list`
      outside a repository was told "Not a git repository. karr requires Git."
      and then handed the file and line of the builder that said so; every
      remote failure ended with a line number in whichever `Cmd/*` had called
      the sync; and `karr-foundation` reported a broken config the same way.
      Those, plus the pipe/fork/log-open failures in the foundation runner, now
      go through `App::karr::Error::user_error` and print the message alone. The
      four commands that let a Path::Tiny error out raw — `karr restore
      --input` on an unreadable file, `karr backup --output` and `karr context
      --write` into a directory karr may not write, `karr init --claude-skill`
      into an unwritable `.claude` — now name the path the user typed and the
      reason the OS gave, and nothing else. Two errors deliberately keep their
      call site, because there it is the useful part: saving an unpersisted
      ref-backed task, which is a programming error, and `croak` in
      App::karr::Foundation's YAML report, whose parser message names its own
      document, line and column and is passed through whole rather than reduced
      to one line.
    - A failed sync now shows git's error once instead of twice. The message
      that ended the command embedded another copy of the multi-line error that

Changes  view on Meta::CPAN

      identity in `refs/karr/meta/board-id` (#95). `karr init` and `karr
      import` stamp it for boards they create (and never re-key an existing
      one); a board from before this change is stamped by the first pull that
      finds no id on either side, with the ordinary push path carrying the
      stamp to the remote; a clone meeting a stamped remote for the first
      time adopts that id. A pull where both sides have an id and they differ
      is refused before any reconciliation, naming what happened and the two
      ways through: `karr sync --push` to republish this board over the wrong
      remote, or the new `karr sync --accept-foreign-board` to adopt the
      remote's board. `karr restore` keeps the board's standing identity when
      the snapshot predates the stamp, so restoring a backup onto its own
      board never looks like a foreign takeover.
    - Fixed `karr pick` being blind to a board imported from kanban-md. An
      optional frontmatter field set to the empty string counted as set,
      because a Moo predicate only knows whether the attribute was passed —
      and `claimed_by: ""` is exactly what a kanban-md card carries once it has
      been read and rewritten, or written by hand. `karr import` stored it
      verbatim, `karr pick` read it as a claim by somebody, and so on a freshly
      imported board pick answered "No available tasks to pick." while
      `karr list` showed the very same work sitting in backlog. An empty
      `claimed_by` is now no claim, the same test `karr move`/`edit`/`delete`

Changes  view on Meta::CPAN

    - Fixed `karr handoff` overwriting concurrent changes. It read the task,
      changed it and saved it back without checking the card had not moved in
      between, so a claim landing in that window was silently replaced instead
      of obeyed. The handoff now goes through the same compare-and-swap and the
      same status-change path as `karr move`, so the claim rule is applied to
      the revision that actually gets written.
    - Fixed `karr pick` locks being published to the remote. Lock refs lived at
      `refs/karr/tasks/N/lock`, inside the namespace karr pushes, so any sync
      that fired while a lock was held put it on the remote; other clones then
      pulled a lock whose holder they could not see, could not outlive and
      could only clear with `karr unlock`. Board backups snapshotted it too.
      Locks are process-local state, so they now live under `refs/karr-local/`,
      which nothing pushes, fetches, prunes or snapshots — and which
      `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

Changes  view on Meta::CPAN

      remote too, because the push insurance faithfully mirrored the
      half-executed destruction. Every ref name is now validated and every
      commit object built before the first ref moves, so a snapshot karr cannot
      apply is refused with the board untouched, and the refs it can apply are
      overwritten in place instead of starting from an empty namespace. The ref
      updates themselves are still a loop rather than one transaction — an I/O
      failure part-way through can still leave a board holding a mix of old and
      new refs — but the board is no longer emptied before the first write, so
      no failure can leave it with nothing in it. A snapshot may also no longer
      address refs outside `refs/karr/`, which previously let a hand-edited
      backup overwrite a branch.
    - Fixed any write command silently seeding a partial board in whichever
      repository it was run in, and that partial board then locking `karr init`
      out of it for good — `karr create` typed in the wrong directory was
      enough, and `karr destroy --yes` was the only way back. A board now
      counts as existing only when `refs/karr/config` is present; the commands
      that write to the board refuse with "No karr board found" when it is
      absent, and `karr init` completes a half-board (keeping its ID counter,
      so existing tasks are not overwritten) instead of refusing. `backup`,
      `destroy`, `materialize` and `repair` still work on whatever is under
      `refs/karr/`, so a half-board an older karr left behind can still be
      inspected and removed. `karr import --yes` is still allowed to bootstrap
      a board from a bare kanban-md `tasks/` view, and now writes the default
      config ref when the view has no `config.yml`, so the board it leaves
      behind is one the writing commands accept. The read-only commands
      (`list`, `board`, `show`, `context`, `log`, `config get`) are unchanged:
      they still report an empty board with the default config rather than
      refusing.
    - Fixed a ref deletion that did not happen reporting success. `delete_ref`

Changes  view on Meta::CPAN

      "waiting ---"` was enough, and YAML dumps such a value unquoted — cut the
      frontmatter mid-line. Every command that loads the board then died with
      "Missing required arguments: id, title", `delete` included, so the board
      could not be repaired with karr at all. The parser now scans for `---` at
      a line start, matching kanban-md.
    - Fixed UTF-8 being encoded twice everywhere. karr passed YAML::XS::Dump
      output (octets) around as characters, mixed that with character-level
      file I/O, and never decoded `@ARGV`. Non-ASCII text was therefore stored
      mojibaked in the refs, handed to agents mojibaked through `--json`,
      written three encodes deep by `materialize`, and destroyed by
      `backup`/`restore`; a correctly encoded kanban-md task file could not be
      imported at all ("invalid trailing UTF-8 octet"). `karr show` looked
      right only because two errors cancelled out. karr now keeps character
      strings internally and encodes only at its edges — argv, stdout/stderr,
      Git ref blobs, YAML and JSON — so non-ASCII titles, bodies, tags, and
      board names round-trip and kanban-md interop works outside ASCII.
    - Boards written by earlier versions keep working and are read correctly:
      the double encoding is undone on load for any board without the new
      `refs/karr/meta/encoding` marker. New command `karr repair` makes that
      permanent — it reports by default, rewrites the affected refs with
      `--yes`, and stamps the marker so nothing guesses at the board's bytes

Changes  view on Meta::CPAN

      results tail) and `Task->to_json_hash` (the frontmatter+body payload of
      show/pick/handoff). The previously untested `--json` paths of
      move/edit/show/pick/handoff are now pinned by tests.
    - Internal refactor, no user-visible behavior change: the ~900-line
      karr-foundation module is split along its natural seams into three
      focused collaborators — `Foundation::Runner` (agent command execution
      and common-error classification), `Foundation::State` (lock file, JSON
      state, cooldown backoff, attempt counters) and `Foundation::Overview`
      (the read-only dashboard) — with `Foundation` staying the orchestrator
      and delegating, so every existing call site keeps working unchanged.
    - `karr backup`, `karr restore` and `karr destroy` now run through the
      shared sync lifecycle instead of hand-rolled pull/push calls, so the
      most destructive commands get the same 3x pull/push retry and
      push-on-crash insurance as every other mutating command (backup is
      read-only and takes only the retrying pull — it never pushes).
    - `karr config` and `karr skill` now accept options placed before the
      action (`karr config --json show`, `karr skill --json install`) instead
      of misreading the leading flag as the action ("Unknown action: --json").
      Both commands read their action (and config's key/value) through the same
      option-aware positional parsing the id-taking commands already use, now
      extracted into a shared `App::karr::Role::CliArgs`. Surplus positionals
      are rejected too: `karr skill` takes exactly the action, and `karr config`
      enforces its per-action arity (`config get KEY extra` is rejected).
    - Internal dead-code cleanup, no user-visible behavior change: remove the

Changes  view on Meta::CPAN

      libgit2; the scan-directory sweep additionally confirms the resolved
      repository root is the scanned child itself, so a plain directory nested
      inside a karr repo is not mistaken for a board.
    - Fix the documented global `--dir` option being silently ignored:
      commands always operated on the board of the current directory, so
      `karr --dir /other/repo create "X"` quietly wrote to — and synced —
      whatever board the caller happened to be standing in. `--dir` is now a
      real option on every board command (`karr list --dir PATH`, the root
      form `karr --dir PATH list`, and bare `karr --dir PATH` for the board
      summary all work), seeding repository discovery before any store or
      sync activity; init, backup, restore,
      and destroy, which previously hardcoded the current directory, honor
      it too. A `--dir` that does not lead to a Git repository fails loudly
      instead of falling back to the current directory.
    - Options may now be placed before, between, or after positional
      arguments (`karr archive --json 1`, `karr handoff --claim tester 1`,
      `karr edit --title New 3`), matching how the kanban-md CLI behaves.
      Previously the raw option token was read as the task id, giving opaque
      errors like "Task --claim not found" — and `karr show --last N` crashed
      outright. The real positionals are now extracted from argv using each
      command's own option metadata (value-taking options swallow their

Changes  view on Meta::CPAN

    - Skip user.email test gracefully when not configured

0.101     2026-03-23 03:02:05Z

    - Strengthen docs and GitHub landing pages
    - Add POD to all modules (bin/karr, BoardStore, commands, roles)

0.100     2026-03-23 01:50:27Z

    - Migrate board state to git refs only (refs/karr/*), drop karr/ directory
    - Add backup and restore commands for refs/karr snapshot export/import
    - Add destroy command (remove all board refs, local and remote)
    - Add set-refs / get-refs helper commands for arbitrary ref storage
    - Add skill command with File::ShareDir-based skill loading
    - Simplify board output and drop WIP limits from config
    - Split Docker runtime images (slim + full) with entrypoint script
    - Expand POD across Git.pm, Lock.pm, commands, and roles

0.003     2026-03-20 05:01:01Z

    - BREAKING: Git sync stores task data in commit-wrapped refs (pushable/fetchable)

MANIFEST  view on Meta::CPAN

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
t/260-context-write-to-with-output-flags.t
t/263-error-shows-working-command.t
t/264-working-command-frequent.t
t/266-dependency-check-coverage.t
t/27-backup-restore.t
t/28-destroy.t
t/29-worktree.t
t/30-foundation.t
t/31-foundation-drain.t
t/32-show.t
t/33-foundation-run.t
t/34-foundation-command.t
t/35-sync-error.t
t/36-task-clear.t
t/37-board-render.t

bin/karr  view on Meta::CPAN

=head1 VERSION

version 0.600

=head1 SYNOPSIS

    karr init --name "My Project"
    karr create "Fix login bug" --priority high
    karr move 1 in-progress --claim swift-fox
    karr board
    karr backup > karr-backup.yml

=head1 DESCRIPTION

F<karr> is the primary command line interface for L<App::karr>. It manages a
Git-native kanban board whose canonical state lives in C<refs/karr/*>, plus
optional helper payloads in non-protected refs outside that namespace.

Run it from inside a Git repository. Commands discover the repository root,
pull the current board refs, and read and write task cards directly against
those refs -- no board files ever touch the work tree for an ordinary

bin/karr  view on Meta::CPAN


    karr init --name "HandyIntelligence Prototype" --claude-skill

Claim and start the next task for a generated agent:

    NAME=$(karr agentname)
    karr pick --claim "$NAME" --move in-progress

Export the board before trying a destructive restore:

    karr backup > karr-backup.yml
    karr restore --yes < karr-backup.yml

=head1 SEE ALSO

L<App::karr>, L<App::karr::Task>, L<App::karr::BoardStore>,
L<App::karr::Git>, L<App::karr::Cmd::Init>, L<App::karr::Cmd::Skill>,
L<App::karr::Cmd::Backup>, L<App::karr::Cmd::Restore>

=head1 SUPPORT

=head2 Issues

docs/superpowers/plans/2026-03-22-ref-first-board-implementation.md  view on Meta::CPAN

# Ref-First Board Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Replace the persistent `karr/` working tree with a Git-ref-first board model and add destructive backup/restore for `refs/karr/*`.

**Architecture:** Treat `refs/karr/*` as the sole source of truth. Read and write commands operate on ref-backed state, using temporary materialization only as an internal implementation detail. Config overrides live in `refs/karr/config`, the next n...

**Tech Stack:** Perl, Dist::Zilla, Git refs, YAML::XS, existing `App::karr::*` command modules, `Test::More`.

---

## Chunk 1: Ref-backed board storage

### Task 1: Add a board store abstraction

**Files:**

docs/superpowers/plans/2026-03-22-ref-first-board-implementation.md  view on Meta::CPAN

- [ ] **Step 1: Add failing tests for write operations with no persistent `karr/` directory**
- [ ] **Step 2: Run `prove -l t/03-archive.t t/04-handoff.t t/16-pick-lock.t t/20-next-id-collision.t t/26-ref-first-writes.t`**
Expected: FAIL on direct file operations.
- [ ] **Step 3: Refactor each command to fetch refs, mutate ref-backed state, and push refs back**
- [ ] **Step 4: Move numeric id allocation to `refs/karr/meta/next-id`**
- [ ] **Step 5: Re-run the focused tests**
Expected: PASS.

## Chunk 4: Backup, restore, migration, and docs

### Task 7: Add backup and restore commands

**Files:**
- Create: `lib/App/karr/Cmd/Backup.pm`
- Create: `lib/App/karr/Cmd/Restore.pm`
- Modify: `lib/App/karr.pm`
- Modify: `bin/karr`
- Test: `t/27-backup-restore.t`

- [ ] **Step 1: Write failing tests for YAML backup, destructive restore, and `--yes` protection**
- [ ] **Step 2: Run `prove -l t/27-backup-restore.t`**
Expected: FAIL because the commands do not exist yet.
- [ ] **Step 3: Implement `backup` to emit all `refs/karr/*` as YAML to a file or stdout**
- [ ] **Step 4: Implement `restore` to require `--yes`, delete all current Karr refs, recreate the snapshot refs, and push them**
- [ ] **Step 5: Run `prove -l t/27-backup-restore.t`**
Expected: PASS.

### Task 8: Clean up docs, tests, and Docker skill behavior

**Files:**
- Modify: `README.md`
- Modify: `share/claude-skill.md`
- Modify: `lib/App/karr.pm`
- Modify: `lib/App/karr/Cmd/Init.pm`
- Modify: `lib/App/karr/Cmd/Skill.pm`

docs/superpowers/specs/2026-03-22-ref-first-board-design.md  view on Meta::CPAN

- reuse of existing task markdown format
- no persistent `karr/` directory

This is preferable to rewriting every command to manipulate raw YAML strings in
one step.

## Backup and restore

Add explicit snapshot commands:

- `karr backup [FILE|-]`
- `karr restore [FILE|-] --yes`

Backup writes a YAML snapshot of all `refs/karr/*`.

Restore is destructive by design. With `--yes`, it should:

1. fetch current refs
2. delete every existing `refs/karr/*`
3. recreate refs from the YAML snapshot
4. push the resulting ref set

Without `--yes`, restore must fail with a strong warning. The command should
make it obvious that refs missing from the backup will be removed.

## Skill installation and Docker

Skill installation must keep working when `karr` is run through Docker. The
important requirement is that the target `HOME` inside the container is the one
that actually contains mounted `.codex`, `.claude`, or `.cursor` directories,
and that the final process runs as a non-root user where appropriate.

This is separate from board storage, but it should be verified during the same
cleanup pass because the current vendor-style usage depends on it.

docs/superpowers/specs/2026-03-22-ref-first-board-design.md  view on Meta::CPAN

- `next_id` needs atomic handling so concurrent `create` operations stay safe
- restore is intentionally destructive and needs strong UX guardrails

## Recommendation

Implement the ref-first architecture in stages:

1. introduce a ref-backed board store abstraction
2. move config and task loading off persistent `karr/`
3. make `init` Git-only and ref-only
4. add backup/restore
5. remove remaining local board assumptions from docs, tests, and help text

docs/superpowers/specs/2026-05-15-role-boardaccess-split-design.md  view on Meta::CPAN


No `board_dir`, no temp dir, no task loading. Commands access tasks via `$store->load_tasks()`, `$store->save_task($task)`, etc.

### Role::SyncLifecycle

Provides:
- `sync_before()` — runs `git pull` with 3 retries and clear error messages on failure
- `sync_after()` — runs `git push` with 3 retries and clear error messages on failure
- Returns a `SyncGuard` object from `sync_before()` that acts as insurance: if `die`/`croak` happens before `sync_after` is called, the Guard's `DESTROY` runs `sync_after`

Commands explicitly call `sync_after()` after successful work. Guard is backup insurance only.

### SyncGuard

```perl
package App::karr::SyncGuard;

has git           => (is => 'ro', required => 1);
has _errors       => (is => 'ro', default => sub { [] });
has _done         => (is => 'rw', default => 0);

docs/superpowers/specs/2026-05-15-role-boardaccess-split-design.md  view on Meta::CPAN

    die "Push failed after 3 attempts. Local refs are intact.\n"
      . "Run 'karr sync' to retry.\n"
      . "Errors: " . join(', ', $self->errs) . "\n";
}
```

Commands use:
```perl
sub execute {
    my ($self, $args_ref, $chain_ref) = @_;
    my $guard = $self->sync_before;  # backup insurance

    # ... command logic ...
    # may die/croak, guard handles cleanup in DESTROY

    $self->sync_after;  # explicit call for clarity
    undef $guard;       # mark done, DESTROY no-ops
}
```

### No Materialized Temp Dir

ex/README.md  view on Meta::CPAN


### Board administration

```bash
karr config                    # zusammengeführte Einstellungen ansehen
karr config get foundation.enabled
karr config set foundation.enabled false
karr config --defaults         # die eingebauten Vorgaben statt dieses Boards

karr sync                      # refs/karr/* explizit holen und pushen
karr backup > karr-backup.yml  # ganzes Board als YAML
karr restore --yes < karr-backup.yml
karr destroy --yes
```

`karr sync` synchronizes `refs/karr/*` with the remote — without flags first
fetch, then push, plus one delete refspec for each ref that this clone has
deleted and not yet published (read off the tombstones under
`refs/karr-local/deleted/`). The push does **no prune**: a remote ref this clone
has never seen is another agent's card and not leftover — pruning it is the way
a card has actually been lost once.

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

  [ archive   => 'Archive a task (soft-delete)' ],
  [ handoff   => 'Hand off a task for review' ],
  [ needs     => 'Report or resolve cross-board dependencies' ],
  [ destroy   => 'Delete the entire refs/karr/* board' ],
  [ config    => 'View or modify board config' ],
  [ disable   => 'Disable automated agent runs on this board' ],
  [ enable    => 'Re-enable automated agent runs on this board' ],
  [ context   => 'Generate board context summary' ],
  [ log       => 'Show activity log' ],
  [ metrics   => 'Show flow metrics' ],
  [ backup    => 'Export refs/karr/* as YAML' ],
  [ restore   => 'Replace refs/karr/* from YAML' ],
  [ materialize => 'Write refs/karr/* out as a tasks/ file view' ],
  [ import    => 'Import a tasks/ file view into refs/karr/*' ],
  [ repair    => 'Migrate a 0.402-or-earlier board off double-encoded UTF-8' ],
  [ sync      => 'Sync board with remote' ],
  [ 'agent-name' => 'Generate a random agent name' ],
  [ skill     => 'Install/update agent skills' ],
  [ 'set-refs' => 'Store helper payloads in a Git ref' ],
  [ 'get-refs' => 'Fetch and print helper payloads from a Git ref' ],
);

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

  # and anywhere else it is an unknown option that exits 2 (#254). The old
  # parenthesis named two of them and read like a shortened list.
  $out .= "  --compact    Compact output (board, config, context, dashboard,\n";
  $out .= "               list, log, metrics, pick, show)\n";
  $out .= "\n" . colored("EXAMPLES:", 'bold') . "\n";
  $out .= "  karr init --name \"My Project\"\n";
  $out .= "  karr create --title \"Fix login bug\" --priority high\n";
  $out .= "  karr list --status todo,in-progress\n";
  $out .= "  karr move 1 in-progress --claim agent-fox\n";
  $out .= "  karr pick --claim agent-fox --move in-progress\n";
  $out .= "  karr backup > karr-backup.yml\n";
  $out .= "  karr restore --yes < karr-backup.yml\n";
  $out .= "  karr set-refs superpowers/spec/1234.md draft ready\n";
  $out .= "  karr board\n";
  $out .= "\nRun " . colored("karr <command> --help", 'bold') . " for command-specific options.\n";

  # Exit-code contract (ADR 0002): a positive code here is a usage/option-parse
  # error from MooX::Options (unknown option, bad value on the root command), so
  # normalize it to 2. Help requests (-h/--help) arrive with code 0 -> exit 0.
  # A negative code means "print, do not exit" and is left untouched.
  $code = 2 if $code > 0;

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

The installed executable is L<karr>. Running C<karr> without a subcommand shows
the board summary by default, and the command-specific modules under
C<App::karr::Cmd::*> implement the individual operations.

Use L<karr> when you want to learn:

=over 4

=item * which command to run for a task

=item * how backup, restore, destroy, and helper refs fit together

=item * which module implements each subcommand

=item * how to use the Docker-wrapped CLI day to day

=back

=head1 DOCKER RUNTIME

Perl installation remains the normal development path, but Docker is a

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

    my $whole = $store->board_exists;

Callers state the refusal through L<App::karr::Role::BoardDiscovery/require_board>
rather than testing this themselves: a repository that fails this check may
still hold a half-board's tasks, and the two cases need different words (#133).

=head2 has_board_refs

True when anything at all lives under C<refs/karr/>, initialized board or not.
This is the question the commands that clean up or read raw refs
(C<backup>, C<destroy>, C<materialize>, C<repair>) actually have: refusing them
on a half-board would strand the refs a pre-fix karr already left behind, with
no way to remove them from inside karr.

    my $anything_here = $store->has_board_refs;

=head2 load_config_overrides

Returns the board's raw config overrides -- whatever C<refs/karr/config>
currently holds, decoded but not merged with the code defaults. A board with
no config ref yet, or one whose ref does not decode to a mapping, answers

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

L</stamp_encoding_version>.

    $store->serialize_from( $git_root );

=head2 snapshot

Reads every ref under C<refs/karr/> into a plain hash reference:
C<< { version => 1, refs => { $ref_name => $content, ... } } >>, where
C<$content> is that ref's raw stored text (config YAML, a task's Markdown
document, or a bare id/hex string for the meta refs) via
L<App::karr::Git/read_ref>. C<karr backup> writes this straight to YAML; pair
with L</restore_snapshot> to write one back onto C<refs/karr/*>.

    my $snapshot = $store->snapshot;

=head2 restore_snapshot

Makes the board consist of exactly the refs in the snapshot. Every ref name is
checked and every commit object built before the first ref moves, so a snapshot
karr cannot write is refused with the board untouched instead of destroying it
on the way through (#47). See L<App::karr::Git/replace_board_refs>.

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

# ABSTRACT: Export the ref-backed karr board as YAML

package App::karr::Cmd::Backup;
our $VERSION = '0.600';
use Moo;
use MooX::Cmd;
use MooX::Options (
  usage_string => 'USAGE: karr backup [--output PATH]',
);
use Path::Tiny;
use App::karr::Encoding qw( yaml_dump );
use App::karr::Error qw( user_error clean_error command_hint );
use App::karr::Role::BoardDiscovery;
use App::karr::Role::CliArgs;
use App::karr::Role::SyncLifecycle;

with 'App::karr::Role::BoardDiscovery';
with 'App::karr::Role::SyncLifecycle';

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

  # CLI's :encoding(UTF-8) layer encodes for stdout. A Dump() here would emit
  # octets and both paths would encode them a second time (ticket #53).
  my $yaml = yaml_dump( $store->snapshot );

  if ( $self->output ) {
    my $file = path( $self->output );
    # An --output karr cannot write is the user's path, not karr's: Path::Tiny
    # would otherwise report this file and line at them (#77).
    eval { $file->parent->mkpath; $file->spew_utf8($yaml); 1 }
      or user_error( "Could not write $file: ", clean_error($@) );
    print STDERR "Wrote backup to $file\n";
    return;
  }

  print $yaml;
}

1;

__END__

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

=head1 NAME

App::karr::Cmd::Backup - Export the ref-backed karr board as YAML

=head1 VERSION

version 0.600

=head1 SYNOPSIS

    karr backup > karr-backup.yml
    karr backup --output karr-backup.yml

=head1 DESCRIPTION

Exports the complete C<refs/karr/*> namespace as a YAML snapshot. The default
mode writes the snapshot to standard output so it can be redirected or piped.
Use C<--output> when you want C<karr> to write the file directly.

=head1 OPTIONS

=over 4

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

    my $content = eval { path( $self->input )->slurp_utf8 };
    defined $content
      or user_error( "Could not read ", $self->input, ": ", clean_error($@) );
    return $content;
  }

  # 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 $content = do { local $/; <STDIN> };
  die "No backup payload received on stdin\n"
    unless defined $content && length $content;
  return from_octets($content);
}

1;

__END__

=pod

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

=head1 NAME

App::karr::Cmd::Restore - Restore the ref-backed karr board from YAML

=head1 VERSION

version 0.600

=head1 SYNOPSIS

    karr restore --yes < karr-backup.yml
    karr restore --yes --input karr-backup.yml

=head1 DESCRIPTION

Replaces the complete C<refs/karr/*> namespace with a previously exported YAML
snapshot. This is intentionally destructive: refs currently present but absent
from the snapshot are deleted as part of the restore.

It is not destructive on the way in, though. Every ref name in the snapshot is
checked and every commit object written before the first ref moves, so a
snapshot karr cannot apply -- an unusable ref name, or one outside

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

        return () unless $self->write_ref_cas( NEXT_ID_REF, ($id + 1) . "\n", $oid );
        return $id;
    } );
}


# ----- Whole-board replacement (restore) -----

# The mirror image of validate_helper_ref, which keeps helper refs out of the
# board namespace: a snapshot may only address refs inside it. Without this a
# hand-edited backup could point refs/heads/main at a parentless karr commit,
# because restore fed whatever keys the YAML carried straight to
# reference_create.
sub validate_board_ref {
    my ( $self, $ref ) = @_;
    defined $ref && length $ref
        or die "karr: snapshot contains a ref with no name\n";
    die "karr: '$ref' is outside the board namespace " . BOARD_ROOT . "\n"
        unless index( $ref, BOARD_ROOT ) == 0;
    die "karr: '$ref' is not a valid git ref name\n"
        unless Git::Native->reference_name_is_valid($ref);

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

remote's value.

=head2 validate_board_ref

    my $ref = $git->validate_board_ref($ref);

The mirror image of L</validate_helper_ref>: dies unless C<$ref> is
non-empty, inside the board namespace C<refs/karr/>, and a syntactically
valid git ref name. Returns C<$ref> unchanged on success.
L</replace_board_refs> (C<karr restore>) validates every ref in a snapshot
through this before writing anything, so a hand-edited backup can't point a
ref like C<refs/heads/main> at a board commit.

=head2 replace_board_refs

    $git->replace_board_refs( \%refs );   # { $ref => $content, ... }

Makes the board consist of exactly the given refs: C<karr restore>'s
primitive. Every ref name is validated (L</validate_board_ref>) and every
commit object built I<before> any ref is touched, so a single bad name or
non-text value in C<%refs> dies without leaving the board half-overwritten.

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


Lock refs live under C<refs/karr-local/>, which nothing pushes, fetches, prunes
or snapshots. A lock says "this process, in this clone, is mid-pick right now",
and that sentence has no meaning anywhere else: a clone that receives one cannot
tell whether the holder is still alive, and has no way to find out.

They used to live at C<refs/karr/tasks/N/lock>, inside the namespace C<karr>
pushes. Any sync that fired while a lock was held published it, other clones
pulled it, and it then blocked their picks until somebody ran C<karr unlock> --
a lock that outlived the process holding it and the machine it ran on (#93). It
also turned every board backup into a snapshot of somebody's momentary lock.
Moving the refs out is what makes that impossible, rather than making it depend
on the timing of when a lock happens to be released.

Locks left in the old place by a C<karr> older than this one -- or pulled from a
remote that still has them -- are not acted on: they cannot say anything about
this process, and a pick's exclusivity does not rest on them anyway. They are
not ignored either. C<locks> reports them, marked C<legacy>, and C<break_lock>
clears them, so C<karr unlock> is the way out of the mess the old layout left
behind.

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

unrelated repository -- and that partial board then locked C<karr init> out of
it permanently (#62).

It distinguishes the two ways of not having a board, because they call for
different things from the reader (#133):

=over 4

=item * nothing under C<refs/karr/> -- "No karr board found:", followed by
C<karr init> on its own line (L<App::karr::Error/command_hint>), the same
message C<backup>, C<destroy>, C<materialize> and C<repair> raise off
L<App::karr::BoardStore/has_board_refs> for the same state;

=item * refs present, C<refs/karr/config> missing -- a half-board: the message
names it as one, says how many task refs are at stake, and says that C<karr
init> completes it without discarding them.

=back

Call it B<after> C<sync_before>, never before: on a fresh clone the board only
exists on the remote until the pull has run, and checking first would report a
board that is merely not fetched yet as missing. The four commands that read or
clean up raw refs (C<backup>, C<destroy>, C<materialize>, C<repair>) ask
L<App::karr::BoardStore/has_board_refs> instead, so they can still deal with a
half-board left behind by an older karr.

The read-only commands do not sync, so they cannot use this method; they ask
L</require_local_board>, which puts C<karr sync> in front of C<karr init> for
exactly the fresh clone this one may assume has already been pulled.

=head2 require_local_board

    $self->require_local_board;   # no sync_before: reads stay offline

share/claude-skill.md  view on Meta::CPAN

`created` up to that `created` — karr wrote `started` as a bare date until
ticket #68, which reads as midnight and so lands before a card created later
the same day. A clamped card then asserts zero queue time and no longer
records that its stamp was ever day-granular, so the dry run tells you how
many cards that is before you apply it. It reports, but does not touch,
`completed` stamps with the same day-granular problem.

### Backup and restore

```bash
karr backup > karr-backup.yml
karr restore --yes < karr-backup.yml
```

`restore` is destructive and replaces the entire `refs/karr/*` namespace.

### Destroy

```bash
karr destroy --yes
```

Deletes the entire `refs/karr/*` namespace from the repository and prunes the
remote board state too when a remote is configured. Prefer taking a
`karr backup` first.

### Helper refs

```bash
karr set-refs superpowers/spec/1234.md draft ready
karr set-refs superpowers/spec/1234.md < design.md    # multi-line payload
karr get-refs superpowers/spec/1234.md
```

Stores and retrieves helper payloads in Git refs outside protected namespaces

share/claude-skill.md  view on Meta::CPAN

3. **What's on the board?** → `karr board` or `karr list`
4. **Starting work?** → `karr pick --claim NAME --move in-progress`
5. **Done with task, hand to review?** → `karr handoff ID --claim NAME --note "reason"`
6. **Done with task, close it?** → `karr edit ID --release && karr move ID done`
7. **Blocked?** → `karr edit ID --block "reason"`
8. **Need details?** → `karr show ID`
9. **Soft-delete?** → `karr archive ID`
10. **Board snapshot for agent context?** → `karr context --write-to AGENTS.md`
11. **Check/change config?** → `karr config` / `karr config set KEY VALUE`
12. **Install agent skills?** → `karr skill install`
13. **Need a full board snapshot?** → `karr backup` / `karr restore --yes`
14. **Need shared non-task workflow data?** → `karr set-refs` / `karr get-refs`
15. **Board should never be drained by an automation host?** → `karr disable --reason "why"`
16. **Need to remove the board completely?** → `karr destroy --yes`
17. **Overview of every board under a directory?** → `karr dashboard`

## Multi-agent workflow

```bash
# 1. Generate agent name and pick task
NAME=$(karr agent-name)

t/104-board-identity-lifecycle.t  view on Meta::CPAN

subtest 'restore keeps the standing identity when the snapshot predates it' => sub {
    my $repo = _repo();
    my $git  = App::karr::Git->new( dir => $repo );
    my $store = App::karr::BoardStore->new( git => $git );

    $store->save_config( App::karr::Config->default_config );
    $store->ensure_board_id;
    my $standing = $store->board_id;
    ok defined $standing, 'setup: the board has an identity';

    # A backup taken before identities existed carries no board-id. Installing
    # it verbatim would strip the identity, and the push that follows would
    # prune it off the remote too -- the board would disarm itself.
    my $snapshot = $store->snapshot;
    delete $snapshot->{refs}{'refs/karr/meta/board-id'};

    ok $store->restore_snapshot($snapshot), 'the restore runs';
    is $store->board_id, $standing,
        'and the identity the board already had is still its identity';
};

t/120-error-message-sweep.t  view on Meta::CPAN

# "no source location" assertion here fails against it:
#
#   $ cd /tmp/not-a-repo && karr list
#   Not a git repository. karr requires Git.
#    at /.../lib/App/karr/Role/BoardDiscovery.pm line 124.
#
#   $ karr restore --input /nonexistent/nope.yml --yes
#   Error open (<:unix) on '/nonexistent/nope.yml': No such file or directory
#    at /.../lib/App/karr/Cmd/Restore.pm line 101.
#
#   $ karr backup --output <unwritable>/x.yml
#   Error spew on '...': ... Permission denied at /.../lib/App/karr/Cmd/Backup.pm line 75.
#
#   $ karr context --write-to <unwritable>/ctx.md
#   Error spew on '...': ... Permission denied at /.../lib/App/karr/Cmd/Context.pm line 213.
#
#   $ karr init --claude-skill        # into an unwritable .claude
#   mkpath failed for .../.claude/skills: Permission denied
#    at /.../lib/App/karr/Cmd/Init.pm line 171.
#
#   $ karr-foundation --config <sequence.yml>

t/120-error-message-sweep.t  view on Meta::CPAN

        or diag "stderr was:\n$r->{stderr}";
    unlike $r->{stderr}, qr/BoardDiscovery\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";
    is scalar( grep { length } split /\n/, $r->{stderr} ), 1,
        'exactly one line of error'
        or diag "stderr was:\n$r->{stderr}";
};

subtest 'karr restore names the --input it could not read' => sub {
    my $repo = new_board();
    my $missing = path($repo)->child('no-such-backup.yml');
    my $r = run_karr( $repo, 'restore', '--yes', '--input', "$missing" );

    isnt $r->{exit}, 0, 'the restore fails';
    like $r->{stderr}, qr/Could not read \Q$missing\E/, 'the path the user typed is named';
    like $r->{stderr}, qr/No such file or directory/,   'the reason from the OS survives';
    unlike $r->{stderr}, qr/ at \S+ line \d+/, 'no source location'
        or diag "stderr was:\n$r->{stderr}";
    unlike $r->{stderr}, qr/Restore\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";
};

subtest 'karr backup reports an --output it cannot write' => sub {
    plan skip_all => 'running as root: an unwritable directory is still writable'
        if $ROOT_USER;
    my $repo = new_board();
    my $dir = unwritable_dir($repo) or plan skip_all => "cannot chmod: $!";
    my $target = $dir->child('backup.yml');

    my $r = run_karr( $repo, 'backup', '--output', "$target" );
    chmod 0700, "$dir";

    isnt $r->{exit}, 0, 'the backup fails';
    like $r->{stderr}, qr/Could not write \Q$target\E/, 'the target is named';
    like $r->{stderr}, qr/Permission denied/,           'the reason survives';
    unlike $r->{stderr}, qr/ at \S+ line \d+/, 'no source location'
        or diag "stderr was:\n$r->{stderr}";
    unlike $r->{stderr}, qr/Backup\.pm/, 'no karr module path'
        or diag "stderr was:\n$r->{stderr}";
};

subtest 'karr context --write reports an unwritable parent directory' => sub {
    plan skip_all => 'running as root: an unwritable directory is still writable'

t/155-restore-atomic.t  view on Meta::CPAN


    is( _run_karr( $work, 'init', '--name', 'Atomic Board' )->{exit}, 0, 'board initialized' );
    is( _run_karr( $work, 'create', "task $_" )->{exit}, 0, "task $_ created" ) for 1 .. 3;

    return ( $root, $work );
}

sub _snapshot_of {
    my ( $work, $root ) = @_;
    my $file = "$root/good.yml";
    is( _run_karr( $work, 'backup', '--output', $file )->{exit}, 0, 'backup written' );
    return ( $file, yaml_load( path($file)->slurp_utf8 ) );
}

subtest 'a write failure in phase two leaves the board untouched' => sub {
    my ( $root, $work ) = _board();
    my ( $file, $snapshot ) = _snapshot_of( $work, $root );

    # Mutate the snapshot so its contents differ from the live board on
    # EVERY ref. Without this change a "the refs read back the same"
    # assertion would still pass on the pre-fix code, because the snapshot

t/191-foundation-questions.t  view on Meta::CPAN


    my $id = $box->ask( question => 'Ship it?' );
    $box->settle( $id, 'yes' );
    is( $box->resolve($id)->{state}, 'answered', 'the matching answer counts' );

    # What two clones minting the same id between syncs leaves behind: an
    # answer ref that survived and an ask ref that is now somebody else's
    # question. Simulated by writing the ask ref to a different question.
    App::karr::Git->new( dir => "$repo" )->write_ref(
        'refs/karr-foundation/questions/1/ask',
        "id: 1\nquestion: Delete the backups?\nasked: " . stamp(-60) . "\npolicy: block\n" );

    my $warned = '';
    my $r = do {
        local $SIG{__WARN__} = sub { $warned .= $_[0] };
        $box->resolve($id);
    };
    is( $r->{state}, 'open',
        'an answer to a different question does not settle this one' );
    like( $warned, qr/different question/, 'and it says so out loud' );

t/263-error-shows-working-command.t  view on Meta::CPAN

#### materialize and repair

# Five commands raise this, four off App::karr::BoardStore/has_board_refs and
# one through App::karr::Role::BoardDiscovery/require_board. They are asserted
# together and character for character: the first pass of k263 converted only
# materialize and repair, which left the same sentence spelled two ways
# depending on which command a caller happened to type -- worse than either
# wording on its own, and invisible to a test that checks one command at a time.
subtest 'every board-less repository gets the same message, word for word' => sub {
    my @argv = (
        [ 'backup' ],
        [ 'destroy', '--yes' ],
        [ 'materialize' ],
        [ 'repair' ],
        [ 'create', 'a card' ],   # via require_board, not has_board_refs
    );

    for my $argv (@argv) {
        my $repo = _bare_repo();
        my $name = join ' ', 'karr', @$argv;
        my $rv   = _run_karr( $repo, @$argv );

t/27-backup-restore.t  view on Meta::CPAN

require_git_c();
use TestKarr qw( run_karr run_karr_stdin );
use File::Temp qw( tempdir );
use Encode qw( encode_utf8 );

use App::karr::Encoding qw( yaml_load from_octets );
use App::karr::Git;

# Ticket #63: this file used to assert only which refs exist and what next_id
# is, never that a restored task's *content* survived. It therefore stayed green
# with a non-ASCII fixture even while backup/restore was destroying such a board
# (#53). The fixtures below carry non-ASCII and the restore subtests read the
# task back through the CLI.
my $TITLE = "Backup \x{dc}nicode \x{2014} task";
my $BODY  = "Body caf\x{e9} \x{2014} na\x{ef}ve";
my $TAG   = "gr\x{fc}n";

sub _git_ok {
    my (@cmd) = @_;
    my $rc = system(@cmd);
    is($rc, 0, "@cmd");

t/27-backup-restore.t  view on Meta::CPAN

}

sub _init_repo {
    my $repo = tempdir( CLEANUP => 1 );
    _git_ok( 'git', 'init', '-q', $repo );
    _git_ok( 'git', '-C', $repo, 'config', 'user.email', 'test@example.com' );
    _git_ok( 'git', '-C', $repo, 'config', 'user.name', 'Test User' );
    return $repo;
}

subtest 'backup exports refs/karr snapshot as YAML' => sub {
    my $repo = _init_repo();
    is( _run_karr( $repo, undef, 'init', '--name', 'Backup Board' )->{exit}, 0, 'board initialized' );
    is(
        _run_karr( $repo, undef, 'create', encode_utf8($TITLE),
            '--body', encode_utf8($BODY), '--tags', encode_utf8($TAG) )->{exit},
        0, 'task created'
    );

    my $rv = _run_karr( $repo, undef, 'backup' );
    is( $rv->{exit}, 0, 'backup exits successfully' );

    my $snapshot = yaml_load( from_octets( $rv->{stdout} ) );
    is( $snapshot->{version}, 1, 'snapshot version recorded' );
    ok( exists $snapshot->{refs}{'refs/karr/config'}, 'config ref included in snapshot' );
    ok( exists $snapshot->{refs}{'refs/karr/meta/next-id'}, 'next-id ref included in snapshot' );
    ok( exists $snapshot->{refs}{'refs/karr/tasks/1/data'}, 'task ref included in snapshot' );

    # The snapshot is the task document verbatim, so the characters that went in
    # must be in it -- exactly once. Under #53 the YAML on stdout was encoded
    # three times over and neither of these held.

t/27-backup-restore.t  view on Meta::CPAN


subtest 'restore requires --yes and replaces current refs/karr state' => sub {
    my $repo = _init_repo();
    is( _run_karr( $repo, undef, 'init', '--name', 'Restore Board' )->{exit}, 0, 'board initialized' );
    is(
        _run_karr( $repo, undef, 'create', encode_utf8($TITLE),
            '--body', encode_utf8($BODY), '--tags', encode_utf8($TAG) )->{exit},
        0, 'first task created'
    );

    my $backup = _run_karr( $repo, undef, 'backup' );
    is( $backup->{exit}, 0, 'backup succeeds' );

    is( _run_karr( $repo, undef, 'create', 'Second task' )->{exit}, 0, 'second task created after backup' );
    my $git = App::karr::Git->new( dir => $repo );
    $git->write_ref( 'refs/karr/log/transient', qq({"action":"temp"}) );

    my $without_yes = _run_karr( $repo, $backup->{stdout}, 'restore' );
    isnt( $without_yes->{exit}, 0, 'restore without --yes fails' );
    like( $without_yes->{stderr}, qr/destructive/i, 'stderr warns about destructive restore' );

    my $with_yes = _run_karr( $repo, $backup->{stdout}, 'restore', '--yes' );
    is( $with_yes->{exit}, 0, 'restore with --yes succeeds' );
    like( $with_yes->{stderr}, qr/Restored refs\/karr/i, 'restore reports success on stderr' );

    is_deeply( [ $git->list_task_refs ], [1], 'task refs are replaced by snapshot contents' );
    ok( !$git->ref_exists('refs/karr/log/transient'), 'refs absent from snapshot are removed' );
    is( $git->read_next_id_ref, 2, 'next-id metadata is restored from the snapshot' );

    # #63: which refs came back is not the same question as whether what is in
    # them is still the task. Read the restored card and compare it field by
    # field with what was created.

t/46-global-dir-option.t  view on Meta::CPAN

#     remediated from dangling commits.
#
# DESIGN (recorded on ticket #14, `karr show 14`): both call shapes must work
#     or fail LOUDLY -- never silently fall back to cwd:
#       - `karr CMD --dir X`   (Cmd-level option)
#       - `karr --dir X CMD`   (root form; MooX::Cmd passes command_chain to
#          execute(), the Cmd adopts the root's dir)
#     Discovery starts at the given path and still walks up looking for a Git
#     repo (parity note below). Invalid --dir (nonexistent path, or an
#     existing directory that isn't a Git repo) must produce a clean,
#     non-zero-exit error and must NOT operate on the cwd board. init/backup
#     (and by extension restore/destroy) must honour --dir too.
#
# PARITY CHECK (../kanban-md/cmd/root.go, requested on this ticket before
#     writing coverage): kanban-md's --dir is a cobra PersistentFlag bound to
#     a single package-level var, so both "before" and "after" the subcommand
#     name are equivalent to cobra's parser -- there is no root-form/cmd-form
#     distinction there, which is *why* kanban-md never had this bug shape.
#     Its resolveDir() returns flagDir verbatim with NO walk-up when set
#     (internal/config.FindDir's walk-up only applies to the unset/cwd case).
#     karr's already-agreed design deliberately diverges here: --dir is a
#     Git-repo discovery seed, not a literal kanban-directory path, so karr's
#     walk-up applies even when --dir is given (see DESIGN above). This is a
#     recorded, intentional divergence, not an open question.
#
# None of this is implemented yet (no lib/ changes accompany this test).
# Every subtest below was hand-probed against the current tree (VERSION
# 0.304, pre-#14-fix) before writing assertions; the exact current behaviour
# is recorded per subtest. All of them assert the desired post-fix contract,
# so all are expected RED until #14 lands:
#
#   - Cmd-form (`CMD --dir X`) on list/show/backup/init: today MooX::Options
#     doesn't know a `dir` option on the dispatched Cmd class at all, so it
#     dies "Unknown option: dir" (exit 1) before touching any board -- an
#     accidental non-zero exit, but for the wrong reason and with the wrong
#     message, not a validated/clean --dir error.
#   - Root-form (`--dir X CMD`) on list/show/backup: today silently succeeds
#     (exit 0) against the CWD's board, never touching X at all -- the exact
#     incident shape.
#   - Root-form on init: today dies "Board already exists in refs/karr/"
#     (exit 255) because it hardcodes dir => '.' and finds the CWD's
#     pre-existing board -- again non-zero, but for the wrong reason, and it
#     never touches the target repo.
#   - Invalid --dir in root form: today silently succeeds (exit 0) against
#     the CWD board -- the most dangerous shape, explicitly called out in the
#     ticket as "never silently fall back".

t/46-global-dir-option.t  view on Meta::CPAN


    is( _refs_dump($A), $before_A, "board A's refs are unchanged by --dir C2 init" );

    my $c2_refs = _refs_dump($C2);
    like( $c2_refs, qr{refs/karr/config}, 'refs/karr/config now exists in C2' );
};

# ------------------------------------------------------------- (6) Backup on
# B via --dir, in both call shapes.

subtest 'backup --dir B (cmd-form) snapshots B, not A (RED, ticket #14)' => sub {
    my $rv = _run_karr( $A, 'backup', '--dir', $B );

    # Probed today: dies "Unknown option: dir" (exit 1) -- Cmd::Backup has
    # no `dir` option.
    is( $rv->{exit}, 0, 'backup --dir B exits 0' ) or diag $rv->{stderr};
    like( $rv->{stdout}, qr/name: Board B/, 'snapshot contains board B\'s name' );
    like( $rv->{stdout}, qr/B-Only Seed Task/, 'snapshot contains B\'s seed task' );
    unlike( $rv->{stdout}, qr/A-Only Seed Task/, 'snapshot does NOT contain A\'s seed task' );
};

subtest '--dir B backup (root-form) snapshots B, not A (RED, ticket #14)' => sub {
    my $rv = _run_karr( $A, '--dir', $B, 'backup' );

    # Probed today: exit 0, silently dumps A's own snapshot (name: Board A,
    # A-Only Seed Task, plus the "Neu In B" task accidentally created there
    # by an earlier probe) -- Cmd::Backup hardcodes dir => '.'.
    is( $rv->{exit}, 0, '--dir B backup exits 0' ) or diag $rv->{stderr};
    like( $rv->{stdout}, qr/name: Board B/, 'snapshot contains board B\'s name' );
    like( $rv->{stdout}, qr/B-Only Seed Task/, 'snapshot contains B\'s seed task' );
    unlike( $rv->{stdout}, qr/A-Only Seed Task/, 'snapshot does NOT contain A\'s seed task' );
};

# ------------------------------------------------------------ (7) Invalid
# --dir must fail loudly and cleanly, never silently fall back to A. Uses
# fresh System::TMPDIR-rooted paths (never nested under A/B/the repo) so the
# walk-up-inclusive design (see PARITY CHECK above) can't accidentally climb
# into an unrelated real repository above them.

t/54-destructive-sync.t  view on Meta::CPAN


    return ($local, $remote);
}

subtest 'restore mirrors the rewrite to the remote and prunes stale refs' => sub {
    my ( $repo, $remote ) = _init_remote_pair();
    is( _run_karr( $repo, undef, 'init', '--name', 'Restore Board' )->{exit}, 0, 'board initialized' );
    is( _run_karr( $repo, undef, 'create', 'First task' )->{exit}, 0, 'first task created' );
    is( _run_karr( $repo, undef, 'sync' )->{exit}, 0, 'first task synced to remote' );

    my $backup = _run_karr( $repo, undef, 'backup' );
    is( $backup->{exit}, 0, 'backup succeeds' );

    # Second task lands on the remote too, but is NOT in the snapshot.
    is( _run_karr( $repo, undef, 'create', 'Second task' )->{exit}, 0, 'second task created' );
    is( _run_karr( $repo, undef, 'sync' )->{exit}, 0, 'second task synced to remote' );

    my $remote_git = App::karr::Git->new( dir => $remote );
    is_deeply( [ $remote_git->list_task_refs ], [ 1, 2 ], 'remote holds both tasks before restore' );

    my $restore = _run_karr( $repo, $backup->{stdout}, 'restore', '--yes' );
    is( $restore->{exit}, 0, 'restore with --yes succeeds' );

    my $local_git = App::karr::Git->new( dir => $repo );
    is_deeply( [ $local_git->list_task_refs ], [1], 'local refs match the snapshot' );

    # sync_after must have pushed the rewrite with prune, so the remote loses
    # task 2 (present on the remote, absent from the snapshot).
    is_deeply( [ $remote_git->list_task_refs ], [1], 'remote rewrite mirrored: stale task pruned' );
};

subtest 'backup is read-only and never pushes local-only state' => sub {
    my ( $repo, $remote ) = _init_remote_pair();
    is( _run_karr( $repo, undef, 'init', '--name', 'Backup Board' )->{exit}, 0, 'board initialized' );
    is( _run_karr( $repo, undef, 'create', 'First task' )->{exit}, 0, 'task created' );
    is( _run_karr( $repo, undef, 'sync' )->{exit}, 0, 'task synced to remote' );

    # A local-only ref the remote has never seen. If backup ever pushed, this
    # would be mirrored to the remote.
    my $local_git = App::karr::Git->new( dir => $repo );
    $local_git->write_ref( 'refs/karr/log/backup-probe', qq({"probe":1}) );

    my $backup = _run_karr( $repo, undef, 'backup' );
    is( $backup->{exit}, 0, 'backup exits successfully against a repo with a remote' );

    my $snapshot = Load( $backup->{stdout} );
    is( $snapshot->{version}, 1, 'snapshot still produced' );

    my $remote_git = App::karr::Git->new( dir => $remote );
    ok( !$remote_git->ref_exists('refs/karr/log/backup-probe'),
        'read-only backup did not push the local-only ref to the remote' );
    is_deeply( [ $remote_git->list_task_refs ], [1], 'remote task refs left untouched by backup' );
};

done_testing;

t/70-utf8-roundtrip.t  view on Meta::CPAN


  my $rv = _run_karr( $repo, 'import', '--yes' );
  is( $rv->{exit}, 0, 'import --yes succeeds on a kanban-md view' ) or diag $rv->{stderr};
  unlike( $rv->{stderr}, qr/invalid trailing UTF-8 octet/, 'no YAML::XS decode error' );

  is_single_utf8( _blob( $repo, 'refs/karr/tasks/1/data' ), $TITLE, 'imported ref title' );
  is( App::karr::Git->new( dir => $repo )->load_task_ref(1)->title, $TITLE,
    'and it reads back as the same characters' );
};

subtest 'backup and restore preserve a non-ASCII board' => sub {
  my $repo = _init_repo();
  is( _run_karr( $repo, 'init', '--name', 'Snap Board' )->{exit}, 0, 'board initialized' );
  is(
    _run_karr( $repo, 'create', encode_utf8($TITLE),
      '--body', encode_utf8($BODY), '--tags', encode_utf8($TAG) )->{exit},
    0, 'task created'
  );
  my $before = _blob( $repo, 'refs/karr/tasks/1/data' );

  my $file = path($repo)->child('snapshot.yml');
  is( _run_karr( $repo, 'backup', '--output', "$file" )->{exit}, 0, 'backup --output exits 0' );

  my $raw = do { open my $fh, '<:raw', "$file" or die $!; local $/; <$fh> };
  is_single_utf8( $raw, $TITLE, 'snapshot file title' );

  is( _run_karr( $repo, 'restore', '--yes', '--input', "$file" )->{exit}, 0, 'restore exits 0' );

  # Not a byte-for-byte comparison: Git::read_ref chomps the payload's trailing
  # newline to match the old `git cat-file` behaviour, so a backup/restore cycle
  # has always dropped exactly that one byte -- on ASCII boards too, and on
  # 0.402 as well. That is a separate defect; what this subtest is about is that
  # no character is mangled on the way through.
  my $after = _blob( $repo, 'refs/karr/tasks/1/data' );
  is( $after, ( $before =~ s/\n\z//r ), 'the restored ref is the original payload (less the chomped newline)' );
  is_single_utf8( $after, $TITLE, 'restored ref title' );
  is_single_utf8( $after, $BODY,  'restored ref body' );

  my $task = App::karr::Git->new( dir => $repo )->load_task_ref(1);
  is( $task->title, $TITLE, 'restored title reads back as characters' );

t/81-error-messages.t  view on Meta::CPAN

#
# Probed pre-fix, `karr skill install` into an unwritable directory:
#
#   mkpath failed for .claude/skills/kanban-issues-karr-cli: Permission denied at
#   /.../lib/App/karr/Cmd/Skill.pm line 134.
#
# App::karr::Error is the single place that turns such an error into one clean
# line. This file pins the mechanism and its use in App::karr::Cmd::Skill; the
# rest of the sweep -- App::karr::Role::BoardDiscovery, the two sync failures in
# App::karr::Role::SyncLifecycle, App::karr::Foundation, its Runner, and the
# raw Path::Tiny errors from `karr restore` / `backup` / `init` / `context` --
# is pinned in t/120-error-message-sweep.t. App::karr::Git::_ref_error, which
# used to carry its own inline copy of the same reduction, now calls
# clean_error.

subtest 'croak really does ignore the trailing-newline convention' => sub {
    # The premise of the whole ticket. If this ever stops being true, the
    # mechanism below is solving a problem that no longer exists.
    eval { croak "already newline terminated\n" };
    like $@, qr/ at \S+ line \d+/,
        'croak appends a call site even to a newline-terminated message';

t/86-restore-atomicity.t  view on Meta::CPAN


    is( _run_karr( $work, 'init', '--name', 'Restore Board' )->{exit}, 0, 'board initialized' );
    is( _run_karr( $work, 'create', "task $_" )->{exit}, 0, "task $_ created" ) for 1 .. 3;

    return ( $root, $work, $origin );
}

sub _snapshot_of {
    my ( $work, $root ) = @_;
    my $file = "$root/good.yml";
    is( _run_karr( $work, 'backup', '--output', $file )->{exit}, 0, 'backup written' );
    return ( $file, yaml_load( path($file)->slurp_utf8 ) );
}

subtest 'a ref name karr cannot write leaves the board untouched, here and on the remote' => sub {
    my ( $root, $work, $origin ) = _board_with_origin();
    my ( $file, $snapshot ) = _snapshot_of( $work, $root );

    my @before_local  = _local_karr_refs($work);
    my @before_origin = _origin_karr_refs($origin);
    ok( scalar(@before_local),  'board has refs before the restore' );

t/86-restore-atomicity.t  view on Meta::CPAN

        [ _refs( 'git', '-C', $work, 'for-each-ref', '--format=%(refname)', 'refs/heads/smuggled' ) ],
        [], 'and no branch was created from the snapshot' );
};

subtest 'a snapshot karr can write still replaces the board exactly' => sub {
    my ( $root, $work, $origin ) = _board_with_origin();
    my ( $file, $snapshot ) = _snapshot_of( $work, $root );

    # A fourth task exists only in the live board, so the restore has to remove
    # it: restore is destructive on purpose and must stay that way.
    is( _run_karr( $work, 'create', 'task 4' )->{exit}, 0, 'a fourth task is added after the backup' );
    like( _run_karr( $work, 'list' )->{stdout}, qr/task 4/, 'and shows up on the board' );

    my $rv = _run_karr( $work, 'restore', '--yes', '--input', $file );
    is( $rv->{exit}, 0, 'restore succeeds' ) or diag $rv->{stderr};

    is_deeply( [ _local_karr_refs($work) ], [ sort keys %{ $snapshot->{refs} } ],
        'the board holds exactly the refs the snapshot carried' );

    my $list = _run_karr( $work, 'list' );
    like( $list->{stdout}, qr/task 1/, 'the snapshot tasks are back' );



( run in 1.441 second using v1.01-cache-2.11-cpan-4ef0a570458 )