chore: remove nbd project

nbd has been superseded by beans for issue tracking. All tickets were
migrated to .beans/ in the previous commits. Remove the nbd/ project
directory, its flake input, and all references.

- Remove nbd/ (source, tests, docs, beans, skills, flake)
- Remove nbd flake input and nbd package from devShell in flake.nix
- Update flake.lock to drop nbd and its transitive inputs
- Remove nbd entry from PROJECTS.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent 83d5abde8a
commit b335009a21

@ -1,8 +1,5 @@
# TODO
## Tools
- [x] `nbd` agent tasks management cli
## Projects
- [ ] QuotesDB website
- [ ] Local-first Grocery List app

@ -18,38 +18,7 @@
"type": "github"
}
},
"nbd": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
},
"locked": {
"path": "./nbd",
"type": "path"
},
"original": {
"path": "./nbd",
"type": "path"
},
"parent": []
},
"nixpkgs": {
"locked": {
"lastModified": 1771369470,
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0182a361324364ae3f436a63005877674cf45efb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1772956932,
"narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=",
@ -68,33 +37,11 @@
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nbd": "nbd",
"nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay_2"
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nbd",
"nixpkgs"
]
},
"locked": {
"lastModified": 1771816254,
"narHash": "sha256-vkp3iTF6QmHMvL+34DI93IiMPjS2lqcMlA1fl7nXVsQ=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "085bdbf5dde5477538e4c87d1684b6c6df56c0ad",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"rust-overlay_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"

@ -8,10 +8,9 @@
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
nbd.url = "path:./nbd";
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, nbd }:
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
@ -52,9 +51,6 @@
# jq for json parsing
pkgs.jq
# NBD task management
nbd.packages.${system}.nbd
# Task management
pkgs.beans

@ -1,6 +0,0 @@
beans:
path: .beans
prefix: nbd-
id_length: 4
default_status: todo
default_type: task

@ -1,184 +0,0 @@
---
# nbd-08jg
title: Add nbd next subcommand
status: completed
type: feature
priority: high
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:32Z
blocked_by:
- nbd-p9na
---
## Summary
Add `nbd next` subcommand that selects the single highest-priority ticket that is
ready to work on. Supports `--filter` for additional narrowing within the ready set.
Depends on: `--filter` wired into CLI (ticket 887344). Implicitly depends on the
TicketFilter module (ticket c2a024) through that.
## Definition of "ready"
Same semantics as `nbd ready`:
- `status \!= done`
- Every ID in `ticket.dependencies` belongs to a ticket with `status == done`.
- Missing dependency IDs are treated conservatively: the ticket is NOT ready.
## Behaviour
1. Load all tickets with `list_tickets(&root)` (already sorted by priority desc).
2. Build `done_ids` set (same as `cmd_ready`).
3. Find the first ticket (highest priority) where:
- status \!= done
- all dependencies are in done_ids
- `filter.matches(t)` (if a filter was provided)
4. If found: display the single ticket.
5. If not found: print a "no ready tickets" message. Exit 0 (not an error).
## Output
Without `--json`:
- Found: print the ticket in the same tabular format as `nbd read` (via `display::print_ticket`).
- Not found: `No ready tickets.`
With `--json`:
- Found: `{"next": { ...ticket fields including id... }}`
- Not found: `{"next": null}`
Wrapping in `{"next": ...}` (rather than bare ticket or bare null) makes the JSON
unambiguously parseable by consumers — they always get an object with a `next` key.
## Implementation
Add to `Commands` enum:
```rust
/// Choose the highest-priority ticket that is ready to work on.
///
/// A ticket is ready when its status is not `done` and every ticket it
/// depends on has status `done`. Returns the single highest-priority
/// ready ticket, optionally narrowed by `--filter KEY=VALUE`.
///
/// Exits 0 even when no ready ticket exists.
Next {
/// Filter ready tickets: key=value pairs (repeatable).
/// AND between different keys, OR within same key.
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
},
```
Add dispatch arm:
```rust
Commands::Next { filter } => cmd_next(filter, cli.json).await,
```
Implement `cmd_next`:
```rust
async fn cmd_next(filter_args: Vec<String>, json: bool) -> store::Result<()> {
let root = find_nbd_root()?;
let all = list_tickets(&root).await?; // sorted by priority desc
let filter = filter::parse_filters(&filter_args)?;
let done_ids: std::collections::HashSet<&str> = all
.iter()
.filter(|t| t.status == Status::Done)
.map(|t| t.id.as_str())
.collect();
let next = all.iter().find(|t| {
t.status \!= Status::Done
&& t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str()))
&& filter.matches(t)
});
if json {
match next {
Some(ticket) => {
let value = serde_json::json\!({
"next": display::ticket_to_json_value(ticket)
});
println\!("{}", serde_json::to_string_pretty(&value)?);
}
None => println\!("{}", serde_json::json\!({"next": null})),
}
} else {
match next {
Some(ticket) => display::print_ticket(ticket),
None => println\!("No ready tickets."),
}
}
Ok(())
}
```
## display.rs: make ticket_to_json_value pub(crate)
`ticket_to_json_value` is currently private in `display.rs`. It needs to be accessible
from `cmd_next` in `main.rs`. Change its visibility:
```rust
pub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value { ... }
```
This is the cleanest approach — it reuses the existing id-injection logic rather than
duplicating it.
## README update
Add a `### Find the next ticket to work on` section:
```markdown
### Find the next ticket to work on
Returns the single highest-priority ticket that is ready to work on — not done
and with all dependencies completed.
```sh
nbd next
nbd next --json
nbd next --filter type=bug # highest-priority ready bug
nbd next --filter priority=9 # highest-priority ready ticket with priority 9
```
Exits 0 even when no ready ticket exists.
```
## CLAUDE.md update
Update the "Workflow" section to mention `nbd next` as an alternative to `nbd ready`
when the caller just wants to begin the single most important task:
```
**To get the single best ticket to work on next:**
```sh
cargo run -- next --json
```
```
## Files touched
- `src/main.rs``Next` command variant, `cmd_next` handler, dispatch arm
- `src/display.rs``ticket_to_json_value` changed to `pub(crate)`
- `tests/integration.rs` — integration tests
- `README.md` — new section for `nbd next`
- `CLAUDE.md` — update workflow section
## Integration tests to add (tests/integration.rs)
- Three tickets: A (priority 5, no deps), B (priority 8, dep A), C (priority 7, no deps).
`nbd next --json` returns C (highest priority ready ticket — B is blocked by A).
- After marking A done: `nbd next --json` returns B (priority 8, now unblocked).
- With only done tickets: `nbd next --json` returns `{"next": null}`.
- `nbd next` (no `--json`) with no ready tickets prints "No ready tickets." and exits 0.
- `nbd next --filter type=bug --json`: create a bug and a task, both ready.
Returns the bug if it's the highest-priority bug, otherwise the highest-priority bug.
(Create bug priority 8, task priority 9: with filter, should return the bug.)
- `nbd next --json` returns a JSON object with a `"next"` key containing all ticket fields
including `"id"`.
- `nbd next --filter priority=99 --json` returns `{"next": null}` (no ticket has priority 99).

@ -1,73 +0,0 @@
---
# nbd-17dp
title: ASCII graph rendering in display.rs
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:32Z
blocked_by:
- nbd-csdh
---
Add `format_graph` and `print_graph` to `src/display.rs` to render a ticket dependency DAG as an ASCII tree.
## Motivation
The `nbd graph` command needs to convert the `TicketGraph` data structure (from `src/graph.rs`) into a human-readable ASCII representation. Rendering belongs in `display.rs` next to `format_list` and `format_ticket`.
## Output format
The graph is a forest of trees. Each root ticket (no in-graph dependencies) starts at column 0. Its dependents (tickets that depend on it) are indented below it using box-drawing characters.
```
a3f9c2 [todo] Fix login bug
├── b7d41e [todo] Add rate limiting
│ └── c9e823 [in_progress] Write tests
└── d1f302 [done] Update docs
e4a781 [todo] New feature (no deps)
```
**Node format per line:**
```
{prefix}{id} [{status}] {title}
```
Where `{prefix}` is built from indentation characters (`│ `, `├── `, `└── `).
**Blocked indicator (optional):** Consider marking tickets that have unresolved (non-done/closed) dependencies with a `[blocked]` tag or `!` prefix so the graph visually distinguishes ready from blocked tickets.
## Signatures
```rust
/// Render the full dependency forest as an ASCII tree string.
pub fn format_graph(graph: &TicketGraph) -> String
/// Print the full dependency forest.
pub fn print_graph(graph: &TicketGraph)
/// Render the subtree rooted at `root_id` as an ASCII tree string.
pub fn format_subtree(graph: &TicketGraph, root_id: &str) -> String
/// Print the subtree rooted at `root_id`.
pub fn print_subtree(graph: &TicketGraph, root_id: &str)
```
## Implementation notes
- Use a recursive helper that tracks a `prefix: String` carrying the accumulated indentation characters.
- For each node's children (its dependents in the graph), iterate:
- If not the last child: prefix extension is `│ `; connector is `├── `.
- If the last child: prefix extension is ` `; connector is `└── `.
- Cycle guard: track a `visited: HashSet<&str>` across the recursion; if a node ID is already visited, render `{prefix}{connector}{id} [cycle]` and stop descending.
- Status is shown as the serde string: `todo`, `in_progress`, `done`, `closed`.
## Files touched
- `src/display.rs``format_graph`, `print_graph`, `format_subtree`, `print_subtree`
## Tests (unit, in `src/tests.rs`)
- `format_graph` on a single ticket with no deps produces a single line.
- `format_graph` on a two-ticket chain shows the child indented with `└──`.
- `format_graph` with a branching parent shows `├──` for all but the last child and `└──` for the last.
- `format_subtree` only shows the specified root's subtree.
- A cycle in the graph does not cause infinite recursion; the repeated node is labelled `[cycle]`.

@ -1,71 +0,0 @@
---
# nbd-2i1e
title: nbd claude-md command
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
Add `nbd claude-md` subcommand that prints a ready-to-paste CLAUDE.md snippet for adopting `nbd` in any project. The snippet content is maintained as a source file and baked into the binary at compile time via `include_str!`.
## Motivation
Every project that wants to use `nbd` needs the same boilerplate in its CLAUDE.md: what `nbd` is, how to invoke it, the create/update/done workflow, and the key guidelines. Without this command, that block has to be written by hand and tends to drift out of date as `nbd` evolves. By owning the canonical snippet in the `nbd` source tree and embedding it into the binary, the snippet stays in sync with the tool automatically — projects just run `nbd claude-md >> CLAUDE.md` when they adopt or upgrade `nbd`.
## Snippet file
Create `src/claude_md_snippet.md`. This is a plain markdown file, checked into the repository, that contains the CLAUDE.md section any adopting project should paste in. It should cover:
- One-sentence description of `nbd`.
- Initialisation (`nbd init`).
- The four core commands with examples (`create`, `list`, `read`, `update`).
- The `nbd ready` command for finding unblocked tickets.
- The workflow (create before starting → set in_progress → set done).
- Guidelines: always `--json`, priority scale, type choices, dep usage.
The file is written for a project where `nbd` is installed in PATH (i.e. not via `cargo run`). It should be self-contained — no references to the `nbd` crate internals.
## Binary embedding
In `main.rs`, embed the snippet at compile time:
```rust
const CLAUDE_MD_SNIPPET: &str = include_str!("claude_md_snippet.md");
```
`include_str!` resolves paths relative to the source file (`src/`), so this looks for `src/claude_md_snippet.md`. Cargo rebuilds the binary automatically when the file changes.
## Command implementation
Add `ClaudeMd` variant to `Commands` in `main.rs`:
```rust
/// Print a CLAUDE.md snippet for adopting nbd in a project.
ClaudeMd,
```
The `--json` global flag applies:
- Without `--json`: `print!("{CLAUDE_MD_SNIPPET}")` — raw markdown, suitable for redirect (`nbd claude-md >> CLAUDE.md`).
- With `--json`: output `{"snippet": "<escaped content>"}` — for programmatic consumption.
No store access needed — this command is pure output from the embedded constant. It does not call `find_nbd_root()`.
## No display.rs changes needed
The output is a single `print!` call in the command handler. No tabular formatting.
## Tests
Integration tests (`tests/integration.rs`):
- `nbd claude-md` exits zero and stdout is non-empty.
- stdout contains key strings (`"nbd"`, `"CLAUDE.md"` or similar section header, `"--json"`).
- `nbd claude-md --json` exits zero and stdout is valid JSON with a `"snippet"` key whose value is a non-empty string.
- `nbd claude-md` works even when run from a directory with no `.nbd/` (no `find_nbd_root` call).
## Files touched
- `src/claude_md_snippet.md` — new file; the canonical snippet content
- `src/main.rs``include_str!` constant, `ClaudeMd` command variant and handler
- `tests/integration.rs` — integration tests
- `README.md` — mention `nbd claude-md` in the Usage section

@ -1,138 +0,0 @@
---
# nbd-4w8z
title: Exclude done tickets from nbd list by default
status: completed
type: feature
priority: high
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:32Z
blocked_by:
- nbd-p9na
---
## Summary
Change `nbd list` to hide `done` tickets by default. Users must explicitly opt in
via `--filter status=done` or `--filter status=*` to see completed tickets.
This is a **breaking change in default behavior**.
Depends on: `--filter` wired into `list` (ticket 887344).
## Current behaviour
`nbd list` shows ALL tickets regardless of status.
## New behaviour
| Command | Shows |
|---|---|
| `nbd list` | todo + in_progress only (done excluded) |
| `nbd list --filter status=done` | done only |
| `nbd list --filter status=*` | all tickets (all statuses) |
| `nbd list --filter status=todo --filter status=in_progress` | todo and in_progress |
| `nbd list --filter type=bug` | non-done bug tickets (done still excluded) |
| `nbd list --filter type=bug --filter status=*` | all bug tickets including done |
The key rule: **if the user provides no `status` filter key, done tickets are excluded**.
If the user provides any `--filter status=...` argument, the explicit status filter
is used as-is with no implicit exclusion.
## Implementation (src/main.rs)
In `cmd_list`, after parsing the filter and loading tickets, apply the logic:
```rust
let filter = filter::parse_filters(&filter_args)?;
let tickets = list_tickets(&root).await?;
let tickets: Vec<Ticket> = tickets
.into_iter()
.filter(|t| {
// If no status filter was provided by the user, exclude done tickets.
let status_ok = if filter.has_status_filter() {
// User expressed intent about status: use their filter.
filter.matches_status(t)
} else {
// Default: hide done tickets.
t.status \!= Status::Done
};
// Apply remaining filter keys (type, priority, title) regardless.
status_ok && filter.matches_except_status(t)
})
.collect();
```
This requires two additional methods on `TicketFilter` (add to `src/filter.rs`):
```rust
/// Returns true if the ticket's status matches any of the status patterns.
/// Caller is responsible for only calling this when `has_status_filter()` is true.
pub fn matches_status(&self, ticket: &Ticket) -> bool;
/// Returns true if the ticket matches all non-status filter groups (type, priority, title).
/// The status group is intentionally excluded so callers can handle it separately.
pub fn matches_except_status(&self, ticket: &Ticket) -> bool;
```
## CLI help text update
Update the `List` variant doc comment:
```rust
/// List tickets sorted by priority (highest first).
///
/// By default, tickets with status `done` are excluded. Use
/// `--filter status=*` to include all tickets, or
/// `--filter status=done` to show only completed tickets.
List {
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
},
```
## Existing tests that need updating
The integration test `list_shows_created_tickets` creates two tickets with default
status (`todo`) and asserts both appear in `nbd list`. This test is unaffected because
the default tickets are not done. However, any future test that creates a ticket and
immediately lists without marking it done will still work.
Check: is there any existing test that creates a done ticket and expects it in `nbd list`?
If so, update that test to use `--filter status=done` or `--filter status=*`.
## New integration tests to add (tests/integration.rs)
- Create two tickets: one todo, one done. `nbd list` shows only the todo one.
- Create two tickets: one todo, one done. `nbd list --filter status=done` shows only the done one.
- Create two tickets: one todo, one done. `nbd list --filter status=*` shows both.
- Create 3 tickets: bug/todo, bug/done, task/todo.
`nbd list --filter type=bug` shows only bug/todo (done excluded by default).
`nbd list --filter type=bug --filter status=*` shows both bug tickets.
- `nbd list --json` does not include done tickets (verify JSON array length).
- `nbd list --filter status=* --json` includes done tickets.
## README update
Update the "List all tickets" section in README.md:
```
### List all tickets
```sh
nbd list # excludes done tickets
nbd list --filter status=* # all tickets including done
nbd list --filter status=done # only completed tickets
nbd list --filter type=bug # non-done bug tickets
nbd list --json
```
```
## Files touched
- `src/main.rs``cmd_list` implementation and `List` help text
- `src/filter.rs``matches_status`, `matches_except_status` methods
- `src/tests.rs` — unit tests for new filter methods
- `tests/integration.rs` — new tests, update any affected existing tests
- `README.md` — updated usage section

@ -1,95 +0,0 @@
---
# nbd-56ho
title: Tests for nbd graph command
status: completed
type: task
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:32Z
blocked_by:
- nbd-d9dh
---
Add comprehensive unit and integration tests for the `nbd graph` command introduced across tickets `9c9ebe`, `e14172`, and `9ad11f`.
## Unit tests (src/tests.rs)
These test `graph.rs` and the rendering helpers in `display.rs` in isolation using in-memory `Ticket` values (no temp files needed).
### graph.rs tests
```
test_graph_build_empty
TicketGraph::build(&[]) has no nodes.
test_graph_roots_no_deps
Two tickets with no dependencies → both appear in roots().
test_graph_roots_with_chain
Ticket B depends on A → only A is a root.
test_graph_subtree_linear
A → B → C; subtree("a") returns ["a", "b", "c"] (DFS order).
test_graph_subtree_single
subtree of a leaf returns just that ID.
test_graph_subtree_cycle
A.deps = [B], B.deps = [A] → subtree("a") returns both without panic/infinite loop.
test_graph_to_json_nodes_and_edges
Three tickets with two edges → JSON "nodes" has 3 entries, "edges" has 2.
```
### display.rs tests
```
test_format_graph_single_ticket
One ticket, no deps → output contains the ID, status, and title on one line; no connector chars.
test_format_graph_two_ticket_chain
A → B; output has A at col 0 and B indented with "└──".
test_format_graph_branching
A has two dependents B and C; B's line uses "├──" and C's uses "└──".
test_format_subtree_scope
A → B, C (unrelated root); format_subtree(graph, "a") does not contain C's ID.
test_format_graph_cycle_label
Cycle present → output contains "[cycle]" and does not repeat infinitely.
```
## Integration tests (tests/integration.rs)
Use a real temp directory set up with `cargo run -- init` and tickets created via `cmd_create`.
```
test_graph_empty_store
`nbd graph` on an empty store produces an empty line (or at least exits 0).
test_graph_all_no_deps
Create two tickets without deps; `nbd graph` output contains both IDs with no indentation.
test_graph_chain
Create A (no deps) and B (--deps A); `nbd graph` output shows A at top level and B indented.
test_graph_single_ticket
`nbd graph <id>` for A returns only A and its subtree, not unrelated tickets.
test_graph_json_output
`nbd graph --json` parses as valid JSON with "nodes" and "edges" arrays.
test_graph_json_subtree
`nbd graph <id> --json` returns JSON whose "nodes" array contains only reachable tickets.
test_graph_filter
`nbd graph --filter type=bug` only shows bug tickets and their reachable deps.
test_graph_partial_id
`nbd graph <3-char-prefix>` resolves to the correct ticket (prefix resolution).
```
## Files touched
- `src/tests.rs` — unit tests for `TicketGraph` and `format_graph` / `format_subtree`
- `tests/integration.rs` — CLI-level integration tests

@ -1,100 +0,0 @@
---
# nbd-6j0q
title: Print tickets in markdown format instead of key-value table
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Goal
When a ticket is printed to stdout (e.g. by `nbd read`, `nbd create`, `nbd archive`, `nbd next`, `nbd update` without `--json`), it should be rendered as markdown with TOML frontmatter — the same format used by `--ftype md` files on disk — rather than the current key-value table.
`--json` output is unaffected.
## Current output (`nbd read <id>`)
```
ID: a3f9c2
Title: Fix login bug
Body: Users cannot log in with email +
Priority: 8
Status: in_progress
Type: bug
Dependencies: b7d41e, c9e823
```
## Target output
```
+++
title = "Fix login bug"
priority = 8
status = "in_progress"
ticket_type = "bug"
dependencies = ["b7d41e", "c9e823"]
+++
Users cannot log in with email +
```
The `id` field is not in the frontmatter on disk (filename is the source of truth), but for display purposes it should appear as a comment or additional line. Recommended approach: add `id = "a3f9c2"` as the **first key** in the frontmatter so it's immediately visible:
```
+++
id = "a3f9c2"
title = "Fix login bug"
priority = 8
status = "in_progress"
ticket_type = "bug"
dependencies = ["b7d41e", "c9e823"]
+++
Users cannot log in with email +
```
## Files to change
### `src/display.rs`
Replace `format_ticket` with a markdown-rendering implementation. The simplest approach reuses `serialize_markdown` from `src/store.rs`, adding `id` to the frontmatter output.
Option A (preferred): expose `serialize_markdown` from `store.rs` as `pub(crate)` and call it from `display.rs`, prepending the `id` line to the TOML frontmatter block.
Option B: duplicate the logic in `display.rs` as a display-specific formatter that includes `id`.
The `print_ticket` function should call the new formatter.
### `src/tests.rs`
Update any unit tests for `format_ticket` that check the old key-value table format. They should now expect TOML frontmatter.
### `tests/integration.rs`
Update any integration tests that check the plain-text output of `nbd read`, `nbd create`, etc.
## Commands affected
Any command that calls `display::print_ticket`:
- `nbd create` (non-JSON path)
- `nbd read` (non-JSON path)
- `nbd archive` (non-JSON path)
- `nbd next` (non-JSON path, single ticket)
- `nbd update` currently calls `print_diff`, so it is **not** affected
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
cargo run -- create --title "Test ticket" --body "Some body text" --priority 7 --type bug
# Output should be:
# +++
# id = "<id>"
# title = "Test ticket"
# priority = 7
# status = "todo"
# ticket_type = "bug"
# dependencies = []
# +++
# Some body text
```

@ -1,83 +0,0 @@
---
# nbd-7cab
title: Add backlog status to tickets
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
## Goal
Add a `backlog` status variant so tickets can be created without immediately surfacing in the active work queue. Tickets in `backlog` are created but intentionally deferred; they should not appear in `nbd list`, `nbd ready`, or `nbd next` by default.
## Semantics
- `backlog` = created, but not yet ready to be worked on (intentionally deferred)
- Default status for new tickets remains `todo`
- `backlog` tickets are excluded from `nbd list` (same as `done` and `closed`)
- `backlog` tickets are excluded from `nbd ready` and `nbd next`
- `backlog` tickets do **not** count as resolved for dependency purposes (unlike `done` and `closed`) — a dependency on a `backlog` ticket still blocks
- `backlog` tickets are visible with `--all`, `--filter status=backlog`, or `--filter status=*`
## Files to change
### `src/ticket.rs`
Add `Backlog` variant to the `Status` enum:
```rust
pub enum Status {
#[default]
Todo,
InProgress,
Done,
Closed,
Backlog, // new
}
```
Serialises as `"backlog"`.
### `src/main.rs`
- `parse_status`: add `"backlog" => Ok(Status::Backlog)` arm and update the error message
- `cmd_list`: exclude `Status::Backlog` in the default (no `--all`, no `status=` filter) case, alongside `Done` and `Closed`
- `cmd_ready`: exclude `Status::Backlog` from the ready set (a backlog ticket is never ready)
- `cmd_next`: same exclusion as `cmd_ready`
### `src/display.rs`
- `status_str`: add `Status::Backlog => "backlog"` arm
### `src/graph.rs`
- `status_str` (internal helper, duplicated): add `Status::Backlog => "backlog"` arm
### README.md
- Update the Status table to include `backlog`
- Update `nbd list` usage examples to mention that `backlog` is also hidden by default
### CLAUDE.md
- Update CLI Interface block to show `backlog` as a valid status value
### `src/tests.rs`
- Add unit test: a ticket with `Status::Backlog` is excluded from the ready list and is visible under `--all`
### `tests/integration.rs`
- Add integration test: create a ticket with `--status backlog`, confirm it does not appear in plain `nbd list` or `nbd ready`, and does appear in `nbd list --all`
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
cargo run -- create --title "Backlog item" --status backlog --json
cargo run -- list --json # should NOT appear
cargo run -- list --all --json # should appear
cargo run -- ready --json # should NOT appear
```

@ -1,51 +0,0 @@
---
# nbd-8yn8
title: Add status convenience sub-commands (open, start, complete, close)
status: todo
type: feature
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
## Problem
`nbd update <id> --status <x>` is verbose for the most common lifecycle transitions. `nbd archive` already exists as a top-level convenience — the same pattern should apply to all transitions.
## Sub-commands to add
| Command | Status set |
|---|---|
| `nbd open <id>` | `todo` |
| `nbd start <id>` | `in_progress` |
| `nbd complete <id>` | `done` |
| `nbd close <id>` | `closed` |
(`nbd archive` already exists — do not duplicate it.)
## Implementation
**`src/main.rs`**
Add four variants to the `Commands` enum, following the existing `Archive` pattern (line 183):
```rust
Open { id: String },
Start { id: String },
Complete { id: String },
Close { id: String },
```
Add handler functions `cmd_open`, `cmd_start`, `cmd_complete`, `cmd_close`. Each handler should:
1. Call `resolve_id` (supports partial IDs)
2. `find_ticket_path` + `detect_format` to preserve existing file format
3. `read_ticket`
4. Set `ticket.status` to the target status
5. `write_ticket` in the same format
6. Print the ticket (tabular or JSON via `--json`)
Wire up in `dispatch()` following the same pattern as `Commands::Archive`.
**`tests/integration.rs`**
Add integration tests for each command analogous to the existing archive test.

@ -1,36 +0,0 @@
---
# nbd-95l6
title: nbd init command
status: completed
type: feature
priority: high
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`.
## Motivation
Currently users must run `mkdir -p .nbd/tickets` manually before first use. This is a friction point — especially for first-time users and agent workflows bootstrapping a new project.
## Approach
1. Add `Init` variant to the `Commands` enum in `main.rs`.
2. Implement `cmd_init(json: bool) -> store::Result<()>`:
- Get cwd via `std::env::current_dir()`.
- Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent).
- Print confirmation: `initialised .nbd/tickets/ in <path>` (or JSON: `{"root": "<path>"}`).
3. No changes to `store.rs` needed — `ensure_tickets_dir` already uses `create_dir_all` (idempotent).
4. The command should NOT require `.nbd/` to already exist (i.e. do NOT call `find_nbd_root` here — use cwd directly).
## Tests
- Integration test: run `nbd init` in a fresh tempdir, verify `.nbd/tickets/` is created.
- Integration test: run `nbd init` twice in the same dir — succeeds both times (idempotent).
- Integration test: `nbd init --json` outputs valid JSON with a `root` field.
## Files touched
- `src/main.rs` — new `Init` variant and `cmd_init` handler
- `tests/integration.rs` — new integration tests
- `README.md` — update Initialise section

@ -1,52 +0,0 @@
---
# nbd-9mxu
title: Add list status sub-commands (list todo, list backlog, etc.)
status: todo
type: feature
priority: low
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Problem
Filtering by status requires the verbose `--filter status=X`. Common patterns like listing only backlog or only completed tickets should have ergonomic shortcuts.
## Sub-commands to add (positional arg to `list`)
Accept an optional positional `<status>` argument to `nbd list`:
```sh
nbd list backlog # equivalent to: nbd list --filter status=backlog
nbd list closed # equivalent to: nbd list --filter status=closed
nbd list completed # equivalent to: nbd list --filter status=done
nbd list todo # equivalent to: nbd list --filter status=todo
nbd list in_progress # equivalent to: nbd list --filter status=in_progress
```
Note: `completed` is an alias for `done` (avoids the awkward `nbd list done`).
## Implementation
**`src/main.rs`** — `Commands::List`
Add an optional positional argument `status` to the `List` variant:
```rust
List {
status: Option<String>, // new: positional shorthand
filter: Vec<String>,
all: bool,
}
```
In `cmd_list`, when `status` is `Some(s)`:
- Map `"completed"" → "done"`, others pass through
- Treat it as if the caller had passed `--filter status=<s>`
- The explicit `--filter` and `--all` flags should still override as today
If both `status` and `--filter status=...` are given, merge them (OR behaviour within the status group, consistent with `TicketFilter`).
**`tests/integration.rs`**
Add tests for each status shorthand.

@ -1,76 +0,0 @@
---
# nbd-9vrn
title: Add .nbd/config.toml for per-project defaults
status: todo
type: feature
priority: normal
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Problem
All defaults (output format, file type, default status) are hard-coded in the CLI. Users in a project that always uses `--json` or always creates `md`-format tickets must pass these flags repeatedly. A per-project config file would let them set these once.
## Config file
Location: `.nbd/config.toml` (inside the `.nbd/` root, alongside `tickets/` and `cache.db`).
Format (all keys optional — missing keys fall back to compiled defaults):
```toml
[nbd]
json = false # default: false — use tabular output
ftype = "json" # default: "json" — ticket storage format
status = "todo" # default: "todo" — initial status for new tickets
```
## Precedence (highest to lowest)
1. Explicit CLI flag (e.g., `--json`, `--ftype md`)
2. Environment variable (not in scope for this ticket)
3. `.nbd/config.toml`
4. Compiled-in default
## Implementation
**`src/store.rs`** (or a new `src/config.rs`)
Add a `NbdConfig` struct and a `load_config(root: &Path) -> NbdConfig` function:
```rust
pub struct NbdConfig {
pub json: bool,
pub ftype: FileFormat,
pub status: Status,
}
impl Default for NbdConfig { /* compiled-in defaults */ }
pub fn load_config(root: &Path) -> NbdConfig { /* read .nbd/config.toml, fall back to Default */ }
```
Parse with the `toml` crate (already in `Cargo.toml`). Errors in the config file should produce a helpful message to stderr and fall back to defaults rather than aborting.
**`src/main.rs`** — `dispatch()`
After `find_nbd_root()`, call `load_config`. Merge config values with CLI flags:
- `cli.json = cli.json || config.json` (CLI flag wins)
- For `--ftype`: if the user did not pass `--ftype`, use `config.ftype`
- For `--status` in `create`: if the user did not pass `--status`, use `config.status`
**`src/main.rs`** — `cmd_init`
After creating the tickets directory, write `.nbd/config.toml` with default values if it does not already exist.
**`tests/integration.rs`**
- Test that a config with `json = true` causes tabular commands to emit JSON.
- Test that a config with `ftype = "md"` causes `create` to write `.md` files.
- Test that explicit CLI flags override config values.
- Test idempotency of `nbd init` (does not overwrite existing config).
## Dependencies
None. Can be implemented independently of other tickets.

@ -1,79 +0,0 @@
---
# nbd-csdh
title: Add graph computation module (src/graph.rs)
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
Implement `src/graph.rs` — a module that builds a directed dependency graph from a flat list of tickets and provides the data structures needed by the ASCII renderer and JSON output.
## Motivation
The `nbd graph` command (see CLI ticket) needs to traverse `Ticket.dependencies` edges to produce an ordered, tree-structured representation of the dependency DAG. This module isolates that logic from I/O and rendering.
## Data structures
```rust
/// A node in the dependency graph.
pub struct GraphNode<'a> {
pub ticket: &'a Ticket,
/// Direct dependents (tickets that list this ticket as a dependency).
pub dependents: Vec<&'a str>,
/// Direct dependencies (tickets this ticket depends on).
pub dependencies: Vec<&'a str>,
}
/// A directed dependency graph built from a flat list of tickets.
pub struct TicketGraph<'a> {
nodes: IndexMap<&'a str, GraphNode<'a>>,
}
```
## Functions to implement
### `TicketGraph::build(tickets: &[Ticket]) -> TicketGraph`
- Constructs `nodes` map keyed by ticket ID.
- Iterates `ticket.dependencies` to populate both `dependencies` (forward) and `dependents` (reverse) edges.
- IDs in `dependencies` that do not correspond to a known ticket are silently ignored (dangling references are tolerated).
### `TicketGraph::roots(&self) -> Vec<&Ticket>`
- Returns tickets with no dependencies (or whose dependencies are all outside the graph).
- Sorted by priority descending (same ordering as `list_tickets`).
- These become the starting points for the recursive ASCII tree renderer.
### `TicketGraph::subtree(&self, root_id: &str) -> Vec<&str>`
- Returns all ticket IDs reachable from `root_id` by following `dependencies` edges in depth-first order, including `root_id` itself.
- Cycles: track visited set; stop recursion when an ID has been visited. This makes the function safe even if the data contains cycles.
### `TicketGraph::to_json_value(&self) -> serde_json::Value`
- Returns an object like:
```json
{
"nodes": [
{"id": "a3f9c2", "title": "...", "status": "todo", "dependencies": ["b7d41e"]},
...
],
"edges": [
{"from": "a3f9c2", "to": "b7d41e"},
...
]
}
```
## Crate dependencies
No new crates needed. If `IndexMap` insertion-order is useful, `indexmap` can be added — but a `HashMap` with a separate sorted `Vec<&str>` of keys also works. Prefer whatever avoids adding a new crate dependency.
## Files touched
- `src/graph.rs` — new file, public module
- `src/main.rs``mod graph;` declaration
## Tests (unit, in `src/tests.rs`)
- `build` with an empty slice returns an empty graph.
- `roots` returns only tickets with no in-graph dependencies.
- `subtree` returns the correct set of IDs for a linear chain.
- `subtree` does not infinite-loop when the data contains a cycle.
- `to_json_value` contains all expected IDs in `nodes` and all edges in `edges`.

@ -1,96 +0,0 @@
---
# nbd-d9dh
title: Add 'nbd graph' CLI subcommand
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:32Z
blocked_by:
- nbd-csdh
- nbd-17dp
---
Wire up the `graph` subcommand in `src/main.rs` to expose the ASCII dependency graph and JSON output.
## Motivation
With `src/graph.rs` and the rendering functions in `display.rs` implemented, this ticket connects them to the CLI so users (and agents) can invoke `nbd graph`.
## CLI interface
```sh
nbd graph # full dependency forest (all tickets)
nbd graph <id> # subtree rooted at the given ticket ID (or unique prefix)
nbd graph --json # machine-readable adjacency list (all tickets)
nbd graph <id> --json # machine-readable subtree
nbd graph --filter type=bug # forest of only bug tickets and their deps
```
## Clap definition (add to `Commands` enum in main.rs)
```rust
/// Draw an ASCII dependency graph of all tickets, or a subtree rooted at a
/// specific ticket.
///
/// Without an ID, renders every ticket as a dependency forest (roots first,
/// dependencies indented below). With an ID, renders only the subtree reachable
/// from that ticket via its dependencies.
Graph {
/// Optional ticket ID or unique prefix to use as the graph root.
///
/// When omitted, the full dependency forest is rendered.
id: Option<String>,
/// Filter tickets by field before building the graph: repeatable `key=value` pairs.
///
/// Applied to the full ticket list before graph construction.
/// Keys: `status`, `type`, `priority`, `title`. Values support globs.
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
}
```
## Handler: `cmd_graph`
```rust
async fn cmd_graph(id: Option<String>, filter_args: Vec<String>, json: bool) -> store::Result<()>
```
Steps:
1. `find_nbd_root()` and `list_tickets(&root).await?`.
2. Apply `parse_filters(&filter_args)?` to narrow the ticket list.
3. `let graph = TicketGraph::build(&tickets);`
4. If `id` is `Some`:
a. Resolve `id` via `resolve_id(&root, &id).await?`.
b. If `json`: print `graph.to_subtree_json_value(&id)` (or similar — see graph.rs ticket for the JSON shape).
c. Else: `display::print_subtree(&graph, &id)`.
5. If `id` is `None`:
a. If `json`: print `graph.to_json_value()`.
b. Else: `display::print_graph(&graph)`.
## JSON output shapes
**Full graph (`nbd graph --json`):**
```json
{
"nodes": [
{"id": "a3f9c2", "title": "Fix login bug", "status": "todo", "priority": 8, "dependencies": ["b7d41e"]},
...
],
"edges": [
{"from": "a3f9c2", "to": "b7d41e"},
...
]
}
```
**Subtree (`nbd graph <id> --json`):**
Same shape as full graph but only including nodes and edges reachable from `<id>`.
## Files touched
- `src/main.rs``Commands::Graph` variant, `cmd_graph`, dispatch in `dispatch()`
## Depends on
- `9c9ebe` — graph computation module
- `e14172` — ASCII rendering functions

@ -1,56 +0,0 @@
---
# nbd-fgwx
title: nbd update diff output
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
Show a git-diff-style +/- summary of what changed when `nbd update` is run without `--json`.
## Motivation
Currently `nbd update` prints the full ticket after the change, making it hard to see at a glance what actually changed. A diff view — showing only changed fields — is more informative.
## Approach
### display.rs
Add `format_diff(old: &Ticket, new: &Ticket) -> String`:
- Compare each field between `old` and `new`.
- For each field that changed, emit two lines:
```
- status: todo
+ status: in_progress
```
- If no fields changed, emit `(no changes)`.
- Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, `dependencies`.
- `id` is never shown (it cannot change).
- Label width matches `format_ticket` (LABEL_WIDTH).
Add `print_diff(old: &Ticket, new: &Ticket)` that calls `println!("{}" format_diff(...))`.
### main.rs
In `cmd_update`:
- Before applying changes: `let old = ticket.clone();`
- After `write_ticket`:
- If `json`: current behaviour (print new ticket as JSON).
- Else: `display::print_diff(&old, &ticket)`.
## Tests
Unit tests in `src/tests.rs`:
- `format_diff` shows changed fields only.
- `format_diff` with identical tickets outputs `(no changes)`.
- Changed dependencies are shown as comma-separated lists on each line.
Integration test:
- `nbd update <id> --status in_progress` (no `--json`) prints `- status:` and `+ status:` lines.
- `nbd update <id> --json` still prints full JSON (no diff).
## Files touched
- `src/display.rs``format_diff`, `print_diff`
- `src/main.rs``cmd_update` uses `print_diff`
- `src/tests.rs` — unit tests for `format_diff`
- `tests/integration.rs` — integration tests

@ -1,54 +0,0 @@
---
# nbd-flbj
title: Update claude-md snippet to show --json on all commands
status: completed
type: task
priority: low
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
## Goal
The `nbd claude-md` command emits `src/claude_md_snippet.md` verbatim. The current snippet shows core commands without `--json`, contradicting the guideline at the bottom that says to always pass it.
## What to change
In `src/claude_md_snippet.md`, update the **Core commands** section so every example includes `--json`:
```sh
# Create a new ticket (use --ftype md for a human-readable body)
nbd create --title "Add OAuth login" --type feature --priority 7 --ftype md --json
# List all open tickets (sorted by priority)
nbd list --json
# Read a specific ticket
nbd read <id> --json
# Update a ticket
nbd update <id> --status in_progress --json
nbd update <id> --status done --json
```
Also update the **Workflow** numbered list to use `--json` in every command:
```
1. **Before starting** — create a ticket: `nbd create --title "..." --ftype md --json`
2. **When starting** — mark it in progress: `nbd update <id> --status in_progress --json`
3. **When done** — mark it complete: `nbd update <id> --status done --json`
```
The `nbd ready` and `nbd next` sections should also include `--json`.
## Files
- `src/claude_md_snippet.md` — the only file that needs changing
## Validation
```sh
cargo run -- claude-md | grep -c '\-\-json'
```
The count should go up after the change. All existing tests should still pass.

@ -1,44 +0,0 @@
---
# nbd-i0fc
title: nbd ready command
status: completed
type: feature
priority: high
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed.
## Motivation
Agent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately.
## Approach
1. Add `Ready` variant to `Commands` enum in `main.rs`.
2. Implement `cmd_ready(json: bool)`:
a. `list_tickets(root)` to fetch all tickets.
b. Build a set of IDs for tickets with `status == Status::Done`.
c. Filter to tickets where:
- `ticket.status != Status::Done` (not already finished)
- All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready).
d. Print the filtered slice using existing `display::print_list` / `print_list_json`.
3. No new store or display functions needed — reuse existing.
## Edge cases
- A ticket with no dependencies and status `todo` → ready.
- A ticket whose dep is `in_progress` → NOT ready.
- Missing dep ID → NOT ready (treat conservatively).
- Empty store → returns empty list (not an error).
## Tests
Unit-style integration tests:
- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A.
- After marking A done, `nbd ready` should return B.
- `nbd ready --json` returns a JSON array of the ready tickets.
## Files touched
- `src/main.rs` — new `Ready` variant and `cmd_ready` handler
- `tests/integration.rs` — new integration tests

@ -1,91 +0,0 @@
---
# nbd-jc1v
title: 'Split archive/closed: archive=done, closed=cancelled'
status: completed
type: bug
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
## Problem
Currently `nbd archive` sets a ticket's status to `closed`. This conflates two distinct intents:
- A ticket that **was completed** and is being retired from the active list
- A ticket that **will not be completed** (e.g. cancelled, superseded, won't-fix)
## Desired semantics
| Status | Meaning | Resolved (unblocks deps)? | Visible in `nbd list` by default? |
|---|---|---|---|
| `archived` | Completed and soft-deleted from view | Yes | No |
| `closed` | Won't be completed (cancelled/won't-fix) | Yes (avoids deadlock) | No |
Both statuses resolve dependencies, so a ticket depending on an `archived` or `closed` ticket becomes unblocked. This matches current behaviour for `closed`.
## Changes required
### `src/ticket.rs`
Add `Archived` variant to `Status`:
```rust
pub enum Status {
#[default]
Todo,
InProgress,
Done,
Closed,
Archived, // new
}
```
Serialises as `"archived"`.
### `src/main.rs`
- `parse_status`: add `"archived" => Ok(Status::Archived)` arm and update error message
- `cmd_archive`: change `ticket.status = Status::Closed``ticket.status = Status::Archived`
- `cmd_list`: exclude `Status::Archived` alongside `Done` and `Closed` in the default filter path
- `cmd_ready` / `cmd_next`: treat `Status::Archived` as resolved (count it in `done_ids`)
- Update the `Archive` command docstring to say it sets status to `archived` not `closed`
### `src/display.rs`
- `status_str`: add `Status::Archived => "archived"` arm
### `src/graph.rs`
- `status_str` (internal helper): add `Status::Archived => "archived"` arm
### README.md
- Update the Status table to show `archived` as a valid status
- Update `nbd archive` description to say it sets status to `archived`
- Update the note about `archived` vs `closed` semantics
### CLAUDE.md
- Update CLI Interface block to include `archived` in valid status values
- Update `nbd archive` description
### `src/tests.rs` / `tests/integration.rs`
- Update any test that checks `status == "closed"` after `nbd archive` → expect `"archived"` instead
- Add test: `nbd archive <id>` produces `"archived"` status
- Add test: a ticket with `status=closed` (manually set) is also excluded from list by default and treated as resolved
## Migration concern
Existing tickets on disk that have `status: "closed"` set by previous `nbd archive` calls will retain `closed` status. This is acceptable — `closed` remains a valid status. Users who want to distinguish can `nbd update <id> --status archived` on historical tickets, or run `nbd migrate` once the schema is updated.
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
cargo run -- create --title "Test archive" --json | jq -r '.id' | xargs -I{} cargo run -- archive {} --json
# → status should be "archived"
cargo run -- create --title "Cancelled ticket" --json | jq -r '.id' | xargs -I{} cargo run -- update {} --status closed --json
# → status should be "closed"
```

@ -1,105 +0,0 @@
---
# nbd-kq6o
title: Scope nbd next and nbd ready by dependency subtree
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Summary
Add an optional positional `<id>` argument to `nbd next` and `nbd ready` that scopes
the results to the dependency subtree of the given ticket.
## Motivation
When a project has many tickets, you often want to focus on tickets that directly
unblock a specific goal ticket. Currently `nbd next` and `nbd ready` operate over
the entire ticket store, with no way to narrow by project/feature scope except
through `--filter` expressions that don't understand the dependency graph.
## Desired behaviour
```sh
# Highest-priority ready ticket that is a dependency of abc123 (directly or transitively)
nbd next abc123
nbd next abc123 --json
# All ready tickets that are dependencies of abc123 (directly or transitively)
nbd ready abc123
nbd ready abc123 --json
# Both commands still accept --filter alongside the scoping ID
nbd next abc123 --filter type=bug
nbd ready abc123 --filter priority=8
```
The root ticket itself (abc123) is *not* included in the results — only its
dependencies (the tickets it is waiting on).
## Implementation plan
### 1. CLI changes (`src/main.rs`)
In `Commands::Next` and `Commands::Ready`, add an optional positional argument:
```rust
id: Option<String>, // Optional ticket ID or prefix to scope results
```
Update `dispatch` to pass the new argument to `cmd_next` and `cmd_ready`.
Update the CLI doc-comments for both subcommands to mention the scoping
behaviour.
### 2. Logic changes (`src/main.rs`)
In `cmd_next` and `cmd_ready`, after loading all tickets, if `scope_id` is
`Some`:
1. Resolve the prefix to a full ID using `resolve_id`.
2. Build a `TicketGraph` from all tickets (the full list, not filtered).
3. Call `graph.subtree(&resolved_id)` to get the set of all dependency IDs
reachable from the root (this already excludes the root itself when it is
not in its own dependency list).
4. Convert the subtree IDs into a `HashSet`.
5. Restrict the ready/next candidate pool to tickets whose ID is in that set
*and* whose ID is not the scoping ticket itself (the root should never be
returned as a "what to do next" result when it is the scope target).
`TicketGraph::subtree` is already implemented in `src/graph.rs` and handles
cycles correctly via a visited set.
### 3. Test coverage (`tests/integration.rs`)
Add integration tests (using `tempdir`):
- **`test_next_scoped_by_id`**: create a project ticket P with deps [A, B],
where A has dep [C]. Mark C as done. Verify `nbd next P` returns A (the
highest-priority ready dep of P), not P itself or any unrelated ticket.
- **`test_ready_scoped_by_id`**: same setup; verify `nbd ready P` returns
[A] (B is blocked because... actually B has no deps so it's also ready).
Adjust setup so exactly the expected set is returned.
- **`test_next_scoped_no_ready`**: all deps of P are done; verify `nbd next P`
returns no ticket (`{"next": null}` in JSON mode).
- **`test_ready_scoped_with_filter`**: verify `--filter` still narrows within
the scoped set.
### 4. Relevant files
| File | Change |
|---|---|
| `src/main.rs:135-156` | `Commands::Ready` and `Commands::Next` — add `id: Option<String>` |
| `src/main.rs:308-310` | `dispatch` arms for `Next` and `Ready` — pass new arg |
| `src/main.rs:473-511` | `cmd_ready` — add subtree scoping logic |
| `src/main.rs:522-566` | `cmd_next` — add subtree scoping logic |
| `src/graph.rs:165-172` | `TicketGraph::subtree` — already correct, no change needed |
| `tests/integration.rs` | Add 4 new scoped-by-id tests |
### 5. Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -1,48 +0,0 @@
---
# nbd-l38k
title: 'Fix graph orientation: show goals at root, prerequisites as leaves'
status: completed
type: bug
priority: high
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Problem
The dependency graph is inverted. Currently:
- **Roots** = tickets with no dependencies (foundation/leaf-level work)
- **Children** = dependents of the root (tickets blocked by it)
This means the graph reads bottom-up: you see "what blocks what" rather than "what needs what".
## Desired behavior
If ticket A depends on B, C, and D:
- `nbd graph` should show A at the top with B, C, D indented as children (leaves)
- Roots = tickets with **no dependents** (nobody depends on them — these are the top-level goals)
- Children = the **dependencies** of the current node (prerequisites)
This makes the graph read naturally: goals at the top, work to be done first at the bottom.
## Files to change
### `src/graph.rs`
- `roots()`: change predicate from `node.dependencies.is_empty()` to `node.dependents.is_empty()`
- `subtree()` + `dfs_subtree()`: currently traverses `dependents` edges; should traverse `dependencies` edges so subtree(A) returns A + what A depends on (recursively)
- `to_json_value()` and `to_subtree_json_value()`: edge direction in JSON output should be `{"from": dependent_id, "to": dependency_id}` (A→B means A depends on B), or reconsider the `from`/`to` labels in light of the new orientation
- Update all doc comments to reflect the new semantics
### `src/display.rs`
- `render_node()`: iterate `node.dependencies` as children (not `node.dependents`)
- Update doc comments for `format_graph()` and `format_subtree()`
### `src/graph.rs` module-level doc comment
The edge semantics paragraph should be updated: "A is a dependency of B" means A appears as a child in the tree of B.
### Tests
Update any graph-related tests in `src/tests.rs` and `tests/integration.rs` that assert the current (inverted) traversal order.

@ -1,62 +0,0 @@
---
# nbd-lins
title: 'Fix nbd graph <id>: show ancestry path through ticket, not just subtree'
status: todo
type: bug
priority: normal
created_at: 2026-03-10T23:30:29Z
updated_at: 2026-03-10T23:30:31Z
blocked_by:
- nbd-l38k
---
## Problem
Currently `nbd graph <id>` renders the subtree **below** the given ticket via dependent edges (tickets blocked by it). After the graph orientation fix (see ticket 668150), `nbd graph <id>` will render the dependency subtree **below** the given ticket.
But the desired behavior for `nbd graph B` (where A depends on B, C, and D) is more specific:
> `nbd graph B` should graph up to the root level, down to B, and the rest of the way down from B, but not include siblings like C and D.
## Desired behavior
```
A [todo] High-level goal
└── B [todo] Implement feature B
└── E [todo] Write unit tests for B
```
- A is shown because it depends on B (ancestor)
- C and D are NOT shown (they are sibling dependencies of A, not on the path to B)
- E is shown because B depends on E (B's own dependency subtree)
## Algorithm
1. **Ancestors of B**: traverse the `dependents` edges upward (who depends on B, and who depends on those, etc.) — collecting all ancestor tickets
2. **Filtered ancestor tree**: when rendering ancestors, only show the branch that leads toward B — i.e., at each ancestor, only the child that is on the path to B is shown (not siblings like C and D)
3. **B's dependency subtree**: show B and all its dependencies recursively (as per the corrected subtree logic from ticket 668150)
## Implementation approach
In `src/graph.rs`:
- Add `ancestors(target_id: &str) -> Vec<&str>`: collect all IDs that transitively depend on `target_id` (follow `dependents` edges upward)
- Add `ancestry_path_subtree(target_id: &str) -> ???`: returns the combined view — ancestor tree filtered to the path, plus target's dependency subtree
In `src/display.rs`:
- New or updated `format_subtree()` to render the ancestry+subtree combined view
- The ancestry portion must filter children to only show the branch toward the target (not all dependents of each ancestor)
In `src/main.rs`:
- Update `cmd_graph()` to use the new combined view when an ID is provided
## Edge cases
- If B has no dependents (it is itself a root), just show B's dependency subtree (no ancestor section)
- If B appears in multiple dependency chains, show all paths to it
- Cycle detection still applies (mark revisited nodes with `*`)
## Tests
Add integration tests in `tests/integration.rs`:
- Create tickets A, B, C, D, E with A depending on B/C/D, and B depending on E
- Assert `nbd graph B` output contains A and E but not C and D

@ -1,36 +0,0 @@
---
# nbd-m9q2
title: 'nbd init: add cache.db to .nbd/.gitignore'
status: todo
type: task
priority: normal
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Problem
`.nbd/cache.db` is a Turso/libsql SQLite cache file created automatically by `list_tickets_cached`. It should never be committed to git. Currently `nbd init` does not create a `.gitignore` to exclude it.
## Implementation
**`src/main.rs`** — `cmd_init`
After `ensure_tickets_dir` succeeds, write (or append to) `.nbd/.gitignore`:
```
cache.db
```
Use idempotent logic: read the file if it exists, check whether `cache.db` is already listed, and only append/create if it is absent. This keeps `nbd init` safe to run multiple times.
Suggested helper (can be inline in `cmd_init`):
1. Read `.nbd/.gitignore` if it exists.
2. If `cache.db` is not a line in the file, append `cache.db\n`.
3. If the file does not exist, create it with `cache.db\n`.
The JSON output for `--json` should include a `gitignore` key indicating the path that was created/updated (or unchanged).
**`tests/integration.rs`**
Add a test that runs `nbd init` and asserts `.nbd/.gitignore` contains `cache.db`. Run `nbd init` a second time and assert the file is unchanged (idempotent).

@ -1,107 +0,0 @@
---
# nbd-mkkm
title: Add --xml output format that wraps each ticket section in XML tags
status: todo
type: feature
priority: low
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Background
From `nbd/TODO.md`:
> Add a `--xml` output format that prints tickets with XML around each section to make it easier to parse metadata.
Unlike `--json` (which is a complete, structured parse), the XML format adds lightweight tags around each metadata field in the human-readable output. This makes it trivial for scripts to extract specific fields using simple text tools (e.g., `grep`, `sed`, XPath) without pulling in a full JSON parser.
## Intended XML format
For a single ticket (`nbd read <id> --xml`):
```xml
<ticket>
<id>a3f9c2</id>
<title>Fix login bug</title>
<priority>8</priority>
<status>in_progress</status>
<type>bug</type>
<dependencies>
<dep>b7d41e</dep>
<dep>c9e823</dep>
</dependencies>
<body>Users cannot log in with email addresses containing +</body>
</ticket>
```
For a list (`nbd list --xml`):
```xml
<tickets>
<ticket>...</ticket>
<ticket>...</ticket>
</tickets>
```
## Implementation
### `src/main.rs`
Add a global `--xml` flag to the `Cli` struct, parallel to `--json`:
```rust
/// Output XML instead of a human-readable table.
#[arg(long, global = true)]
xml: bool,
```
Precedence: if both `--json` and `--xml` are supplied, `--json` wins (or return an error — TBD, but JSON-first is simpler).
Pass `cli.xml` through `dispatch` to every command handler, alongside `cli.json`.
Each command handler signature gains an `xml: bool` parameter. When `xml` is true and `json` is false, call the new `display::print_ticket_xml` / `display::print_list_xml` functions.
### `src/display.rs`
Add:
- `format_ticket_xml(ticket: &Ticket) -> String` — serialises a single ticket as XML
- `print_ticket_xml(ticket: &Ticket)` — wraps `format_ticket_xml` + `println!`
- `format_list_xml(tickets: &[Ticket]) -> String` — wraps list in `<tickets>`
- `print_list_xml(tickets: &[Ticket])`
XML escaping: at minimum, escape `&`, `<`, `>`, `"`, `'` in field values. Use a small helper `xml_escape(s: &str) -> String` rather than pulling in an XML crate.
For `dependencies`: render each as a `<dep>` child element.
### `src/tests.rs`
Add unit tests:
- `format_ticket_xml` with a ticket that has special characters in the title and body (`&`, `<`, `>`)
- `format_ticket_xml` with empty dependencies
- `format_ticket_xml` with multiple dependencies
- `format_list_xml` with zero and multiple tickets
### `tests/integration.rs`
Add integration tests:
- `nbd read <id> --xml` returns valid XML containing the ticket's fields
- `nbd list --xml` returns valid XML wrapping multiple tickets
- `nbd create ... --xml` returns the created ticket as XML
- `nbd update ... --xml` returns the updated ticket as XML
## Scope
All commands that support `--json` should also support `--xml`:
- `nbd create`
- `nbd read`
- `nbd list`
- `nbd update`
- `nbd ready`
- `nbd next`
- `nbd archive`
- `nbd migrate`
- `nbd graph` (the JSON graph format has a defined structure; XML should mirror it)
- `nbd claude-md` (wrap snippet in `<snippet>` tag)
- `nbd init` (wrap root path in `<init>`)

@ -1,51 +0,0 @@
---
# nbd-n2xz
title: 'Investigate: user-configurable type and status strings with lifecycle phases'
status: draft
type: task
priority: low
created_at: 2026-03-10T23:30:29Z
updated_at: 2026-03-10T23:30:29Z
---
## Problem
`type` and `status` are currently hard-coded Rust enums. Users cannot add custom values (e.g., `status = "review"` or `type = "spike"`) without modifying the codebase. But certain status values (`done`, `archived`, `closed`) have special semantics (excluded from `list`/`ready`/`next`; counted as resolved for deps). Making these extensible requires a design that lets users declare which values are "pre-work", "in-work", and "post-work".
## Design questions
### Lifecycle phases (proposed)
Instead of hard-coding which statuses are excluded, introduce a three-phase model:
| Phase | Examples | Behaviour |
|---|---|---|
| `pre` | `triage`, `backlog` | Excluded from `list`, `ready`, `next`. Not resolved. |
| `during` | `todo`, `in_progress`, `review` | Included in `list`, `ready`, `next`. Not resolved. |
| `post` | `done`, `archived`, `closed` | Excluded from `list`, `ready`, `next`. Counted as resolved. |
Users would configure phases in `.nbd/config.toml`:
```toml
[nbd.status]
pre = ["triage", "backlog"]
during = ["todo", "in_progress", "review", "qa"]
post = ["done", "archived", "closed"]
default = "triage"
```
### Type extensibility
Allow `type` to be any string. Keep built-in types (`project`, `feature`, `task`, `bug`) but do not reject unknown values; validate only that the string is non-empty.
## Questions to answer
1. Is TOML config the right place for lifecycle phase definitions, or should there be a separate schema file?
2. How does serialisation/deserialisation change? `Status` can no longer be an enum if it is user-defined.
3. What is the migration path for existing tickets serialised with the current enum values?
4. Are there cases where a single status should belong to multiple phases (e.g., `review` might be both `during` and gating some post-processing)?
5. What is the minimum viable change? Could we start by making status a `String` internally while keeping the built-in values and their semantics, then layer config-driven phases on top?
## Expected output
Create one or more follow-up tickets with a concrete implementation plan and migration strategy.

@ -1,129 +0,0 @@
---
# nbd-no88
title: Add --version flag with X.Y.Z+GitSha format
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
## Goal
Add a `--version` / `-V` flag to `nbd` that prints the version in the format:
```
0.1.0+7e311d6
```
where `0.1.0` comes from `Cargo.toml` and `7e311d6` is the short git SHA of the commit the binary was built from, embedded at compile time via `build.rs`.
## Why
Allows agents and users to confirm exactly which build of `nbd` is running without inspecting the binary separately.
## Implementation plan
### 1. Create `build.rs` at the crate root
```rust
fn main() {
// Capture the short git SHA at build time.
// Falls back to "unknown" when git is unavailable (e.g. CI without repo).
let sha = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| if o.status.success() { Some(o.stdout) } else { None })
.and_then(|b| String::from_utf8(b).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
println\!("cargo:rustc-env=GIT_SHORT_SHA={sha}");
// Re-run whenever HEAD changes (new commits).
println\!("cargo:rerun-if-changed=.git/HEAD");
println\!("cargo:rerun-if-changed=.git/refs");
}
```
Key points:
- Uses `std::process::Command` — no extra build dependencies.
- Graceful fallback to `"unknown"` when git is absent (clean Nix sandbox builds may not have `.git/`).
- `rerun-if-changed` directives ensure the SHA is refreshed on every commit without forcing a full rebuild every run.
### 2. Add a `VERSION` constant in `src/main.rs`
Add near the top of `main.rs` after the existing `CLAUDE_MD_SNIPPET` constant:
```rust
/// Full version string embedded at compile time: `"X.Y.Z+shortsha"`.
const VERSION: &str = concat\!(env\!("CARGO_PKG_VERSION"), "+", env\!("GIT_SHORT_SHA"));
```
### 3. Wire `VERSION` into the clap `#[command(...)]` attribute
Change:
```rust
#[command(name = "nbd", about = "Manage work tickets for agent workflows")]
```
to:
```rust
#[command(name = "nbd", about = "Manage work tickets for agent workflows", version = VERSION)]
```
clap automatically handles `-V` / `--version` when `version` is set: it prints the string and exits 0. No manual subcommand or dispatch code needed.
### 4. Update `README.md`
Add a brief note in the **Usage** section or **Installation** section:
```sh
nbd --version # prints e.g. 0.1.0+7e311d6
```
### 5. Add an integration test in `tests/integration.rs`
```rust
/// `nbd --version` exits 0 and stdout contains the semver.
#[test]
fn version_flag_exits_zero_with_semver() {
let tmp = tempfile::tempdir().expect("tempdir");
let output = std::process::Command::new(env\!("CARGO_BIN_EXE_nbd"))
.arg("--version")
.current_dir(tmp.path())
.output()
.expect("spawn nbd");
assert\!(output.status.success(), "--version should exit 0");
let stdout = String::from_utf8(output.stdout).unwrap();
// Should contain the package version (semver).
assert\!(stdout.contains("0.1.0"), "--version should include semver: {stdout}");
// Should contain a '+' separator and at least one hex char after it.
assert\!(stdout.contains('+'), "--version should contain '+': {stdout}");
}
```
## Files to change
| File | Change |
|---|---|
| `build.rs` | **Create** — emit `GIT_SHORT_SHA` env var |
| `src/main.rs` | Add `VERSION` const; add `version = VERSION` to `#[command]` |
| `README.md` | Add `nbd --version` example |
| `tests/integration.rs` | Add `version_flag_exits_zero_with_semver` test |
## Edge cases
- **No git available (Nix sandbox):** `build.rs` falls back to `"unknown"`, producing `0.1.0+unknown`. This is acceptable for packaged builds where the version is already pinned by the derivation.
- **clap version output format:** clap 4 prints `nbd X.Y.Z+sha` (prefixed by the binary name) to stdout, then exits 0. This is standard behaviour — the test should check `stdout.contains("0.1.0")` rather than an exact match.
- **No `--json` support for `--version`:** clap intercepts `--version` before dispatch reaches the `--json` handling. This is acceptable; version output is always plain text.
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
cargo run -- --version
# Expected output: nbd 0.1.0+<sha>
```

@ -1,83 +0,0 @@
---
# nbd-o3k8
title: Remove id field from ticket JSON body
status: completed
type: task
priority: critical
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
The ticket ID is already encoded in the filename (`a3f9c2.json`). Storing it redundantly inside the JSON body creates a potential consistency hazard (the two could disagree) and wastes space. The filename should be the sole source of truth for the ID.
## Motivation
- Eliminates a redundancy: filename stem already IS the id.
- Removes the risk of id mismatch (e.g. if a file is renamed manually).
- Simplifies the JSON schema — consumers only need the body fields, not a duplicated key.
## Serde approach
In `ticket.rs`, annotate the `id` field with `#[serde(skip)]`:
```rust
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Ticket {
#[serde(skip)]
pub id: String,
// ...
}
```
`#[serde(skip)]` means:
- **Serialise:** the `id` field is omitted from JSON output entirely.
- **Deserialise:** the field is not read from JSON; it is initialised with `String::default()` (empty string) and must be set manually after deserialization.
Because serde ignores unknown fields by default, **existing files with `"id": "..."` in the JSON body continue to deserialise without error** — the field is simply discarded. This means the change is backwards-compatible for reads; existing stores work immediately. A separate `nbd migrate` command (see companion ticket) cleans up the stale `id` field from disk.
## store.rs changes
`read_ticket(root, id)` — inject id from the `id` parameter after deserialising:
```rust
let mut ticket: Ticket = serde_json::from_slice(&bytes)?;
ticket.id = id.to_string(); // authoritative source: the filename
Ok(ticket)
```
`list_tickets(root)` — inject id from each file's stem:
```rust
let stem = path.file_stem().and_then(|s| s.to_str()).ok_or("invalid filename")?;
let mut ticket: Ticket = serde_json::from_slice(&bytes)?;
ticket.id = stem.to_string();
tickets.push(ticket);
```
`write_ticket` — no change needed; `#[serde(skip)]` already prevents `id` from being written.
`ticket_path` — no change; it already takes `id: &str` as a separate parameter.
## Impact on other tickets
- The `nbd migrate` companion ticket (see deps) provides the command to scrub the old `id` field from existing files.
- Partial ID matching (`resolve_id`) is unaffected — it works on filenames, not JSON content.
- All display, list, and read commands continue to work; `ticket.id` is populated from the filename in every read path.
## Tests
Unit tests (`src/tests.rs`):
- `write_ticket` output does NOT contain the `"id"` key.
- `read_ticket` with a file that has NO `id` field correctly sets `ticket.id` from the parameter.
- `read_ticket` with an old-format file (has `"id"` in JSON) still sets `ticket.id` from the parameter (ignores JSON value).
- `list_tickets` injects correct ids from filenames for all tickets.
- Serialisation roundtrip: write then read, id is preserved via filename not JSON.
Integration tests (`tests/integration.rs`):
- `nbd create` output (tabular and `--json`) contains the correct ID.
- The created `.json` file on disk does NOT contain the `"id"` key.
- `nbd read <id>` displays the correct ID.
## Files touched
- `src/ticket.rs` — add `#[serde(skip)]` to `id`
- `src/store.rs``read_ticket` and `list_tickets` inject id from filename
- `src/tests.rs` — update and add unit tests
- `tests/integration.rs` — add assertion that written files lack `"id"` key

@ -1,70 +0,0 @@
---
# nbd-oziy
title: Turso cache for list performance
status: completed
type: feature
priority: low
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
Add a Turso (libsql) cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores.
## Motivation
`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A Turso/libsql cache avoids re-reading unchanged files by comparing file modification times (mtimes).
## Approach
### Runtime migration: async-std → tokio
libsql requires tokio. Migrate the entire crate:
- `Cargo.toml`: remove `async-std`, add `tokio = { version = "1", features = ["full"] }` and `libsql = "0.6"`
- `src/main.rs`: `#[async_std::main]``#[tokio::main]`, `async_std::fs::remove_file``tokio::fs::remove_file`
- `src/store.rs`: `use async_std::fs``use tokio::fs`, `use async_std::prelude::*` removed (tokio ReadDir uses `.next_entry().await` not stream iteration)
- `src/tests.rs`: `#[async_std::test]``#[tokio::test]`, `async_std::fs``tokio::fs`
### Crate dependency
```toml
tokio = { version = "1", features = ["full"] }
libsql = "0.6"
```
### store.rs additions
New async function `open_cache(root: &Path) -> Result<libsql::Connection>`:
- Opens (or creates) `.nbd/cache.db` via `libsql::Builder::new_local(path).build().await?.connect()`.
- Runs migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`.
New function `list_tickets_cached(root: &Path) -> Result<Vec<Ticket>>`:
1. Scan directory for files + mtimes (using tokio::fs::read_dir + metadata).
2. Open cache DB.
3. For each file: query cache for matching (id, mtime); if hit, deserialise from cached JSON; if miss, read file, parse, upsert to DB.
4. Delete DB rows for IDs no longer on disk.
5. Return sorted by priority desc.
Fall back to `list_tickets` on any cache error.
### main.rs
`cmd_list`, `cmd_ready`, `cmd_next` use `list_tickets_cached` instead of `list_tickets`.
## Files to change
| File | Change |
|---|---|
| `Cargo.toml` | Replace async-std with tokio, add libsql |
| `src/store.rs` | async_std → tokio fs API, add cache functions |
| `src/main.rs` | async_std → tokio main/fs |
| `src/tests.rs` | async_std → tokio test/fs |
| `tests/integration.rs` | Add cache smoke test |
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
cargo run -- list # should create .nbd/cache.db on first run
cargo run -- list # second run uses cache
```

@ -1,155 +0,0 @@
---
# nbd-p9na
title: Wire --filter flag into list, ready, and migrate commands
status: completed
type: feature
priority: critical
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:32Z
blocked_by:
- nbd-zz62
---
## Summary
Add `--filter KEY=VALUE` (repeatable) to the `list`, `ready`, and `migrate` CLI commands.
Parse filter arguments into a `TicketFilter` and apply it in each command handler.
Depends on: TicketFilter module (ticket c2a024).
## CLI changes (src/main.rs)
Add to `Commands::List`, `Commands::Ready`, and `Commands::Migrate` variants:
```rust
/// Filter tickets: key=value pairs (repeatable).
/// Keys: priority, type, status, title.
/// Different keys are ANDed; same key with multiple values is ORed.
/// Values support glob wildcards: status=* matches all statuses.
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
```
Update the `dispatch` function to pass filter args through to each handler.
## Handler changes
### cmd_list(filter_args, json)
```rust
let filter = filter::parse_filters(&filter_args)?;
let tickets: Vec<Ticket> = list_tickets(&root).await?
.into_iter()
.filter(|t| filter.matches(t))
.collect();
```
Note: the default done-exclusion behaviour (ticket for that is separate) will also
live here, layered on top of this filter application.
### cmd_ready(filter_args, json)
Apply the user filter AFTER the ready check. The ready check (not done + all deps done)
is always applied first; the user's filter narrows further within ready tickets.
```rust
let filter = filter::parse_filters(&filter_args)?;
// ... build done_ids as before ...
let ready: Vec<Ticket> = all
.into_iter()
.filter(|t| {
t.status \!= Status::Done
&& t.dependencies.iter().all(|dep| done_ids.contains(dep.as_str()))
&& filter.matches(t)
})
.collect();
```
### cmd_migrate(filter_args, dry_run, json)
For migrate, the filter selects which tickets are candidates for migration.
Tickets not matching the filter are skipped (counted separately, not treated as errors).
Add a `skipped` field to `MigrateReport` in `store.rs`:
```rust
pub struct MigrateReport {
pub updated: usize,
pub already_current: usize,
pub skipped: usize, // NEW: tickets excluded by filter
pub errors: Vec<(String, String)>,
}
```
Update `migrate_tickets` signature in `store.rs`:
```rust
pub async fn migrate_tickets(
root: &Path,
dry_run: bool,
filter: &TicketFilter,
) -> Result<MigrateReport>
```
Inside the per-file loop, after deserialising the ticket, check `filter.matches(&ticket)`.
If false: increment `report.skipped` and continue to next file.
Update `cmd_migrate` to parse the filter and pass it to `migrate_tickets`.
## display.rs changes
Update `format_migrate_report` and `format_migrate_report_json` to include the
`skipped` count:
Human format:
```
Migrated 3 tickets.
Current 5 tickets (already up to date).
Skipped 2 tickets (did not match filter).
Errors 1 ticket could not be migrated:
bad_ticket.json: trailing comma at line 4
```
Only print the "Skipped" line when `skipped > 0`.
JSON format: add `"skipped": N` key to the existing object.
## files touched
- `src/main.rs``filter` fields on List/Ready/Migrate variants, updated dispatch,
updated cmd_list/cmd_ready/cmd_migrate handlers
- `src/store.rs``MigrateReport::skipped`, `migrate_tickets` gains `filter` param
- `src/display.rs` — updated `format_migrate_report` and `format_migrate_report_json`
- `src/tests.rs` — unit tests for updated migrate report formatting
- `tests/integration.rs` — integration tests
## Integration tests to add (tests/integration.rs)
**list filtering:**
- Create tickets: 1 bug/todo, 1 task/in_progress, 1 bug/done.
`nbd list --filter type=bug` shows only the bug tickets (done-exclusion is separate,
but this test can use non-done bugs).
- `nbd list --filter status=in_progress` shows only in_progress tickets.
- `nbd list --filter status=todo --filter status=in_progress` shows both todo and in_progress
(OR within same key).
- `nbd list --filter type=bug --filter status=todo` shows only bug+todo tickets
(AND across keys).
- `nbd list --filter title=*login*` shows only tickets whose title contains "login".
- `nbd list --filter status=*` matches all statuses (wildcard).
- `nbd list --filter type=unknown` exits non-zero with an error (unknown key passes through
as a value, but "unknown" does not match any type → empty results, or error? Error on
unknown key is preferable).
- `nbd list --filter badformat` (no `=`) exits non-zero with an error.
**ready filtering:**
- `nbd ready --filter type=bug` returns only ready bug tickets.
- `nbd ready --filter priority=8` returns only ready tickets with priority 8.
**migrate filtering:**
- Create two tickets. Run `nbd migrate --filter status=todo --dry-run`.
Verify `skipped` count in JSON output matches tickets not matching the filter.
- `nbd migrate --filter status=todo --json` includes `skipped` key.
**error cases:**
- `--filter` with unknown key exits non-zero.
- `--filter` with no `=` exits non-zero.

@ -1,53 +0,0 @@
---
# nbd-pq2x
title: nbd archive command and Closed status
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:29Z
updated_at: 2026-03-10T23:30:29Z
---
Add `Status::Closed` (serialised as `"closed"`) and a convenience `nbd archive <id>` command that sets it.
## Motivation
`done` tickets clutter `nbd list`. `closed` provides a soft-delete: the ticket is preserved on disk but excluded from normal listings by default.
## Approach
### ticket.rs
- Add `Closed` variant to `Status` enum (after `Done`).
- `#[serde(rename_all = "snake_case")]` already handles serialisation → `"closed"`.
### main.rs
- Update `parse_status` to accept `"closed"`.
- Update `status_str` in `display.rs` to map `Status::Closed``"closed"`.
- Add `Archive` variant to `Commands`:
```
Archive { id: String }
```
- Implement `cmd_archive(id, json)`: read ticket → set status to `Closed` → write → print.
This is syntactic sugar for `nbd update <id> --status closed`.
### display.rs
- Add `"closed"` to `status_str` match arm.
### list filtering
- `nbd list` currently shows all tickets. After this change, it should by default **hide** `Closed` tickets.
- Add a `--all` flag to `nbd list` to show all tickets including closed ones.
- Update `list_tickets` or filter at the command handler level. Prefer filtering in `cmd_list` to keep `list_tickets` generic.
## Tests
- Unit test: `Status::Closed` serialises to `"closed"` and back.
- Integration test: `nbd archive <id>` sets status to `closed`.
- Integration test: `nbd list` does not show closed tickets.
- Integration test: `nbd list --all` shows closed tickets.
## Files touched
- `src/ticket.rs` — add `Closed` variant
- `src/main.rs``Archive` command, `parse_status` update, `--all` flag on `list`
- `src/display.rs``status_str` update
- `src/tests.rs` — unit tests
- `tests/integration.rs` — integration tests
- `README.md` — document archive and --all

@ -1,116 +0,0 @@
---
# nbd-q2d1
title: nbd migrate command
status: completed
type: feature
priority: critical
created_at: 2026-03-10T23:30:29Z
updated_at: 2026-03-10T23:30:31Z
blocked_by:
- nbd-o3k8
---
Add `nbd migrate` to bring all ticket files on disk into conformance with the current schema. This is the standard mechanism for handling any schema change — field removals, field additions, renames, or type changes.
## Motivation
Schema changes (like removing `id` from the JSON body, adding new fields with defaults, or renaming a field) leave existing ticket files in an old format. `nbd migrate` re-serialises every ticket through the current serde schema, which:
- **Removes** fields that no longer exist in `Ticket` (they are ignored on deserialise, then absent on re-serialise).
- **Adds** new fields with their `#[serde(default)]` values.
- **Normalises** any formatting differences (e.g. key order, whitespace).
The current immediate use case is scrubbing the `"id"` key from all existing `.json` files after the id-from-filename schema change.
## Design principles
- **Idempotent.** Running `nbd migrate` on an already-current store is a no-op (files are re-written identically).
- **Non-destructive.** A failure on one ticket does not abort the rest; errors are collected and reported at the end.
- **Source of truth unchanged.** If a ticket cannot be parsed, it is left on disk as-is and reported as an error.
- **Dry-run available.** `--dry-run` prints what would change without writing.
## Approach
### main.rs
Add `Migrate` variant to `Commands`:
```
Migrate {
/// Print changes without writing them.
#[arg(long)]
dry_run: bool,
}
```
Implement `cmd_migrate(dry_run: bool) -> store::Result<()>`:
1. `find_nbd_root()`
2. Call `store::migrate_tickets(&root, dry_run).await`
3. Print a summary: `Migrated N tickets (M errors)`.
### store.rs
Add `migrate_tickets(root: &Path, dry_run: bool) -> Result<MigrateReport>`:
1. `fs::read_dir(tickets_dir(root))`
2. For each `*.json`, `*.md`, `*.toml`, `*.jsonb` file (all supported formats — the ones that exist now and future ones added by the multi-format feature):
a. Read the raw bytes.
b. Attempt to deserialise into current `Ticket`, injecting `id` from filename.
c. Re-serialise to the current schema (same format as the original file's extension).
d. Compare raw bytes. If unchanged, skip (count as already-current).
e. If changed and `dry_run`: print `would update {filename}`, do not write.
f. If changed and not `dry_run`: write the new bytes to the same path.
g. If deserialise fails: record the error, leave the file untouched.
3. Return `MigrateReport { updated: usize, already_current: usize, errors: Vec<(String, String)> }`.
```rust
pub struct MigrateReport {
pub updated: usize,
pub already_current: usize,
pub errors: Vec<(String, String)>, // (filename, error message)
}
```
### display.rs
Add `print_migrate_report(report: &MigrateReport)`:
```
Migrated 3 tickets.
Current 5 tickets (already up to date).
Errors 1 ticket could not be migrated:
bad_ticket.json: trailing comma at line 4
```
When `--json`, serialise `MigrateReport` directly (derive `Serialize`).
## How schema changes use this
For **field removal** (e.g. removing `id` from JSON):
- Old files have `"id": "..."` → on deserialise, serde ignores it (unknown field).
- Re-serialise → `id` is absent (since `#[serde(skip)]`).
- File bytes differ → `migrate` rewrites.
For **field addition** (e.g. adding `tags: Vec<String>` later):
- New field in `Ticket` gets `#[serde(default)]`.
- Old files lack `tags` → deserialise gives `vec![]`.
- Re-serialise → `"tags": []` is written.
- File bytes differ → `migrate` rewrites.
## Tests
Unit tests (`src/tests.rs`):
- `migrate_tickets` on a store with old-format files (containing `"id"`) rewrites them without `id`.
- `migrate_tickets` on an already-current store returns `updated: 0`, `already_current: N`.
- `migrate_tickets --dry-run` does not modify files on disk.
- A file with invalid JSON is counted in `errors` and left unchanged.
Integration tests (`tests/integration.rs`):
- Create tickets with old code (inject `id` manually into JSON), run `nbd migrate`, verify `id` is gone from files.
- `nbd migrate --dry-run` reports changes but does not modify files.
- `nbd migrate` exits zero even when some tickets error (but prints error summary).
- `nbd migrate --json` outputs a valid JSON object with `updated`, `already_current`, `errors` fields.
## Files touched
- `src/main.rs``Migrate` command, `cmd_migrate`
- `src/store.rs``migrate_tickets`, `MigrateReport`
- `src/display.rs``print_migrate_report`
- `src/tests.rs` — unit tests
- `tests/integration.rs` — integration tests
- `README.md` — document `nbd migrate`

@ -1,47 +0,0 @@
---
# nbd-ql0c
title: Add next <type> filtered sub-commands (next bug, next feature, next task)
status: todo
type: feature
priority: low
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Problem
`nbd next --filter type=bug` is verbose. When an engineer wants the highest-priority ready bug, they should be able to say `nbd next bug`.
## Sub-commands to add (positional arg to `next`)
Accept an optional positional `<type>` argument to `nbd next`:
```sh
nbd next feature # equivalent to: nbd next --filter type=feature
nbd next task # equivalent to: nbd next --filter type=task
nbd next bug # equivalent to: nbd next --filter type=bug
nbd next project # equivalent to: nbd next --filter type=project
```
## Implementation
**`src/main.rs`** — `Commands::Next`
Add an optional positional argument `ticket_type` to the `Next` variant:
```rust
Next {
ticket_type: Option<String>, // new: positional shorthand
filter: Vec<String>,
}
```
In `cmd_next`, when `ticket_type` is `Some(t)`:
- Validate it is one of `project`, `feature`, `task`, `bug` (return an error otherwise)
- Prepend `format!("type={t}")` to the effective filter list before calling `parse_filters`
Explicit `--filter type=...` values are ORed with the positional shorthand, consistent with `TicketFilter` behaviour.
**`tests/integration.rs`**
Add tests verifying that `next bug` returns only bug-type ready tickets and that `next` with no argument still works as before.

@ -1,46 +0,0 @@
---
# nbd-rulc
title: Partial ID matching
status: completed
type: feature
priority: critical
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
Allow `nbd read`, `nbd update`, and dependency resolution to accept a prefix of a ticket ID (e.g. `nbd read a3f` resolves to `a3f9c2`).
## Motivation
6-character hex IDs are tedious to type in full. Prefix matching — like git's short-SHA resolution — significantly improves interactive ergonomics and makes agent-generated commands shorter.
## Approach
Add `resolve_id(root: &Path, id_or_prefix: &str) -> Result<String>` in `store.rs`:
1. If `id_or_prefix` is exactly 6 characters, try `read_ticket` as-is (fast path, existing behaviour).
2. Otherwise (or if not found), scan `.nbd/tickets/` for files whose stem starts with `id_or_prefix`.
3. Collect all matches.
- 0 matches → error: `"no ticket found matching '{prefix}'"`
- 1 match → return the full ID
- 2+ matches → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."`
Use `resolve_id` inside `cmd_read` and `cmd_update` (replacing the bare `id` string passed to `read_ticket`). Also use it inside `validate_deps` so dependency flags can use short IDs too.
## Tests
Unit tests in `src/tests.rs`:
- Exact 6-char match still works.
- 3-char prefix resolves correctly.
- Ambiguous prefix returns an error listing all matching IDs.
- Unknown prefix returns a not-found error.
Integration tests in `tests/integration.rs`:
- `nbd read <3-char-prefix>` resolves and prints the ticket.
- `nbd update <3-char-prefix> --status done` succeeds.
- Ambiguous prefix exits non-zero with an informative message.
## Files touched
- `src/store.rs` — new `resolve_id` function
- `src/main.rs``cmd_read`, `cmd_update`, `validate_deps` use `resolve_id`
- `src/tests.rs` — unit tests
- `tests/integration.rs` — integration tests

@ -1,87 +0,0 @@
---
# nbd-s16w
title: Multiple file format support (md, toml, jsonb)
status: completed
type: feature
priority: normal
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
Add `--ftype` flag to `create` and `update` to write tickets in markdown, TOML, or binary JSON (CBOR) in addition to the existing JSON format. Format is detected from file extension on read.
## Motivation
Markdown format lets agents write long-form ticket bodies with full markdown syntax, and makes tickets human-readable in a file browser. TOML is a natural config format. CBOR offers compact binary storage.
## Approach
### New crate dependencies (Cargo.toml)
Evaluate and add:
- `toml` — TOML serialisation (likely `toml = "0.8"`)
- `serde_yml` or `serde_yaml` — YAML frontmatter (for `.md` files)
- `ciborium` — CBOR binary JSON (`.jsonb`)
### ticket.rs
No changes needed — `Ticket` already derives `Serialize`/`Deserialize`.
### store.rs
New enum `FileFormat { Json, Markdown, Toml, Jsonb }`.
New function `detect_format(path: &Path) -> FileFormat`:
- `.json``Json`
- `.md``Markdown`
- `.toml``Toml`
- `.jsonb``Jsonb`
- Unknown → `Json` (fallback)
Update `ticket_path(root, id, format)` to use the format-appropriate extension. This is a breaking change to the function signature — update all callers.
Update `read_ticket(root, id)`:
1. Try each known extension in order until a file is found.
2. Read the file and dispatch to the format-appropriate deserialiser.
Serialisation helpers (private):
- `serialize_json(ticket) -> String`
- `serialize_toml(ticket) -> String`
- `serialize_markdown(ticket) -> String` — TOML frontmatter (`+++` delimiters) with body as file content
- `serialize_jsonb(ticket) -> Vec<u8>`
Deserialization helpers (private):
- `deserialize_markdown(bytes) -> Result<Ticket>` — parse frontmatter + body
Update `list_tickets` to scan for `*.json`, `*.md`, `*.toml`, `*.jsonb` files.
Update `write_ticket` to accept `format: FileFormat` and write in the appropriate format.
### main.rs
Add `--ftype [json|md|toml|jsonb]` option (default `json`) to `create` and `update`.
Conversion on `update --ftype`: read old file, write new format, delete old file (if extension changed).
## Markdown format (TOML frontmatter)
```
+++
id = "a3f9c2"
title = "Fix login bug"
priority = 8
status = "in_progress"
ticket_type = "bug"
dependencies = ["b7d41e"]
+++
Long-form body text goes here. Supports full markdown.
```
## Tests
- Unit tests: roundtrip each format (JSON already tested).
- Integration tests: `nbd create --ftype md` creates a `.md` file; `nbd read` finds and parses it.
- Integration test: `nbd update <id> --ftype toml` converts format and removes old file.
## Files touched
- `Cargo.toml` — new dependencies
- `src/store.rs` — format detection, multi-format read/write, updated `list_tickets`
- `src/main.rs``--ftype` flags
- `src/tests.rs` — format roundtrip tests
- `tests/integration.rs` — format integration tests
- `README.md` — document `--ftype`
- `docs/ARCHITECTURE.md` — update storage layout section

@ -1,34 +0,0 @@
---
# nbd-urlz
title: 'Investigate: filtering tickets by project / stream of work'
status: draft
type: task
priority: low
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Problem
When multiple streams of work coexist (e.g., refactoring vs. new feature), there is no way to select tickets for one stream only. `nbd next` and `nbd list` operate across all tickets.
## Questions to answer
1. Does the existing `project`-type ticket + `deps` mechanism serve this need? Could a project ticket act as a grouping node, and filtering by `--filter type=project` or by the project ticket's subtree (`nbd graph <project-id> --json`) provide what is needed?
2. Are there cases where a ticket belongs to multiple projects? If so, a single-parent `deps` tree cannot represent the relationship.
3. Is a dedicated `project` or `label` field (multi-valued) preferable? What are the trade-offs?
4. How does this interact with `nbd ready` and `nbd next`? Would a `--project <id>` flag on these commands be sufficient?
## Approach
Investigate by:
1. Manually modelling two parallel workstreams using `project`-type tickets and `deps`.
2. Evaluating whether `nbd graph <project-id> --json` provides enough to extract a project-scoped ticket list.
3. Writing up findings and creating actionable implementation tickets.
## Expected output
Create one or more follow-up tickets with a concrete implementation plan, or close this ticket with a rationale if the existing tools are sufficient.

@ -1,41 +0,0 @@
---
# nbd-wbf7
title: Nix flake for nbd
status: completed
type: task
priority: low
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
Add `nbd/flake.nix` so that `nbd` can be consumed as a Nix package and used as a CLI tool in other projects in the mono-repo.
## Motivation
Other `vibed` services and external projects should be able to include `nbd` as a dev dependency via Nix, getting a pinned, reproducible binary without needing Cargo installed.
## Approach
### nbd/flake.nix
Follow the pattern from the repo root `flake.nix`. The service flake should:
1. Inherit the base flake's inputs (nixpkgs, rust-overlay, etc.) or declare its own.
2. Define a `packages.default` attribute that builds the `nbd` crate with `rustPlatform.buildRustPackage`.
3. Define a `devShells.default` that includes the `nbd` binary and standard Rust tooling (rustfmt, clippy, cargo).
4. Optionally expose an `apps.default` for `nix run .#nbd`.
### Cargo.lock
Ensure `Cargo.lock` is committed (it already is) — required for reproducible Nix builds.
### cargoHash / cargoSha256
The `buildRustPackage` derivation requires a `cargoHash` (or `cargoSha256`). Use the correct fetcher approach (vendored deps or `fetchCargoTarball`).
## Steps
1. Read the root `flake.nix` to understand the base structure.
2. Write `nbd/flake.nix` following the same conventions.
3. Run `nix build .#nbd` from the `nbd/` directory to verify it builds.
4. Run `nix run .#nbd -- --help` to verify the binary works.
## Files touched
- `nbd/flake.nix` — new file
- `README.md` — add `nix run` usage section

@ -1,79 +0,0 @@
---
# nbd-wi74
title: '''VERSION file: compile into binary via include_str\! and add nbd version subcommand'''
status: todo
type: feature
priority: normal
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Problem
The current version string is assembled in `main.rs` from `CARGO_PKG_VERSION` (Cargo.toml) and `GIT_SHORT_SHA` (build.rs). There is no `nbd version` subcommand — only `--version`. And the `/work` skill does not bump any version on completion.
## Changes required
### 1. Create `VERSION` file
Create a `VERSION` file at the crate root (`nbd/VERSION`) containing the initial version:
```
0.1.0
```
### 2. Use `VERSION` in the binary
In `src/main.rs`, replace `CARGO_PKG_VERSION` with an `include_str\!` of the `VERSION` file (trimmed):
```rust
const VERSION: &str = concat\!(
env\!("CARGO_PKG_VERSION"), // keep for crate metadata
"+",
env\!("GIT_SHORT_SHA"),
);
```
Actually, use `include_str\!("../VERSION")` trimmed and concatenated with the git SHA:
```rust
const VERSION_FILE: &str = include_str\!("../VERSION");
const VERSION: &str = /* VERSION_FILE.trim() + "+" + GIT_SHORT_SHA at runtime or via concat */ ;
```
Note: `include_str\!` and `concat\!` cannot trim at compile time. Use a `build.rs` approach: read `VERSION` in `build.rs`, trim it, and emit it via `cargo:rustc-env=NBD_VERSION=...`. Then:
```rust
const VERSION: &str = concat\!(env\!("NBD_VERSION"), "+", env\!("GIT_SHORT_SHA"));
```
Update `build.rs` to read `VERSION` and emit `NBD_VERSION`.
### 3. Add `nbd version` subcommand
Add a `Version` variant to the `Commands` enum:
```rust
/// Print the nbd version string and exit.
Version,
```
Handler prints the same string as `--version`. With `--json`, output `{"version": "X.Y.Z+sha"}`.
### 4. Update `/work` skill
Edit `.claude/skills/work/SKILL.md` to add a version-bump step after marking a ticket complete:
- Breaking changes (major rework, API change) → bump major: `X.0.0`
- Features (`ticket_type == feature`) → bump minor: `X.Y.0`
- Tasks and bugs (`ticket_type == task | bug`) → bump patch: `X.Y.Z`
The skill should read `VERSION`, parse the semver, increment the appropriate component, and write it back. Also update `Cargo.toml` `version` to match (keep in sync).
## Relevant files
- `nbd/VERSION` (new)
- `nbd/build.rs` (update: emit `NBD_VERSION`)
- `nbd/src/main.rs` (update: use `NBD_VERSION`, add `Version` subcommand)
- `nbd/Cargo.toml` (keep version in sync with `VERSION` file)
- `.claude/skills/work/SKILL.md` (update: add version-bump step)

@ -1,61 +0,0 @@
---
# nbd-y4nv
title: Change graph cycle marker from [cycle] to *
status: completed
type: task
priority: low
created_at: 2026-03-10T23:30:30Z
updated_at: 2026-03-10T23:30:30Z
---
## Goal
When `nbd graph` renders a node that has already been visited (a node appearing in multiple branches of the tree), it currently labels the repeat occurrence as `[cycle]`. This label is misleading — the node isn't truly in a cycle, it's simply appearing twice in the tree because it's depended on from multiple places. Change the marker to `*` to indicate "this ticket appears elsewhere in the tree".
## Current output
```
a3f9c2 [todo] Fix login bug
├── b7d41e [in_progress] Add rate limiting
│ └── c9e823 [todo] Write tests
└── c9e823 [cycle]
```
## Target output
```
a3f9c2 [todo] Fix login bug
├── b7d41e [in_progress] Add rate limiting
│ └── c9e823 [todo] Write tests
└── c9e823 *
```
## Files to change
### `src/display.rs`
In the `render_node` function (around line 409), change the cycle rendering line from:
```rust
append_line(out, &format!("{prefix}{connector}{id} [cycle]"));
```
to:
```rust
append_line(out, &format!("{prefix}{connector}{id} *"));
```
### README.md
Update the `nbd graph` section example to replace `[cycle]` with `*` in the documentation.
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
# Create two tickets that share a dependency, then graph them
cargo run -- graph
```
Any test that checks for `[cycle]` in graph output needs updating to expect `*` instead.

@ -1,85 +0,0 @@
---
# nbd-yh0v
title: 'Add triage status: new default for tickets lacking implementation detail'
status: todo
type: feature
priority: normal
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
## Problem
New tickets are created with `status=todo`, implying they are ready to work on. But many tickets need further research or implementation details before work can begin. The TODO describes a `triage` status for exactly this case — tickets that need an LLM or human to fill in details before they become `todo`.
## Status semantics
- `triage` — the ticket exists but lacks sufficient detail to begin implementation. An LLM or human should flesh out the body and move it to `todo` when ready.
- `triage` is excluded from `nbd ready` and `nbd next` (not actionable yet).
- `triage` is excluded from `nbd list` by default (like `backlog`).
- `triage` is the new **default status** for `nbd create` (replaces `todo`).
## Changes
### `src/ticket.rs`
Add `Triage` variant to `Status`:
```rust
/// The ticket needs more detail before it can be worked on.
///
/// A triage ticket should have its body updated with implementation
/// details and then moved to `todo`.
Triage,
```
Change the `#[default]` attribute from `Todo` to `Triage`.
### `src/main.rs`
- `parse_status`: add `"triage" => Ok(Status::Triage)` and update the error message.
- `Commands::Create`: change the default value for `--status` from `"todo"` to `"triage"`.
- **Note:** If `.nbd/config.toml` support (separate ticket) is implemented first, the default should be settable via config; fall back to `"triage"` if config is absent.
- `cmd_ready` and `cmd_next`: add `Status::Triage` to the exclusion list.
- `cmd_list`: add `Status::Triage` to the default exclusion list.
### `src/filter.rs`
- `status_str`: add `Status::Triage => "triage"`.
### `src/display.rs`
- `status_str`: add `Status::Triage => "triage"`.
- Column widths: `STATUS` column is currently 13 chars (`"in_progress" + 2`). `"triage"" is 6 chars — no width change needed.
### `src/graph.rs`
- `status_str`: add `Status::Triage => "triage"`.
### `src/claude_md_snippet.md`
Update the embedded snippet to document the triage workflow:
- `triage` tickets need implementation details added to their body before they can be worked on.
- When creating a ticket that is ready to work on immediately, pass `--status todo` explicitly.
- Default: `nbd create --title "..."` creates a `triage` ticket.
### `CLAUDE.md` (project-level)
Update the workflow section to reflect the new default and document when to use `--status todo` vs. leaving the default.
### `src/tests.rs`
Add unit tests:
- `Status::Triage` serialises to `"triage"`
- Round-trip deserialisation
- Default `Ticket::new` has `status == Status::Triage`
### `tests/integration.rs`
- `nbd create` with no `--status` flag creates a `triage` ticket.
- `nbd create --status todo` creates a `todo` ticket.
- `nbd ready` does not include `triage` tickets.
- `nbd next` does not include `triage` tickets.
- `nbd list` does not include `triage` tickets by default.
- `nbd list --filter status=triage` shows only triage tickets.
- `nbd list --all` includes `triage` tickets.

@ -1,135 +0,0 @@
---
# nbd-zz62
title: Implement TicketFilter module with glob matching
status: completed
type: feature
priority: critical
created_at: 2026-03-10T23:30:31Z
updated_at: 2026-03-10T23:30:31Z
---
## Summary
Add a `src/filter.rs` module implementing `TicketFilter`, which parses and applies
`key=value` filter expressions against a list of tickets. This is the foundational
building block for the `--filter` flag on `list`, `ready`, `migrate`, and `next` commands.
No external crate is needed — glob matching is implemented with a simple hand-rolled
algorithm that handles `*` wildcards.
## Filter semantics
- `--filter key=value` can be specified multiple times.
- Keys: `priority`, `type`, `status`, `title`.
- **Different keys are ANDed**: `--filter type=bug --filter status=todo` matches tickets
that are BOTH bugs AND todo.
- **Same key, multiple values are ORed**: `--filter status=todo --filter status=in_progress`
matches tickets with EITHER status.
- Values support `*` as a wildcard anywhere in the pattern:
- `status=*` matches any status
- `title=*command*` matches any title containing "command"
- `priority=7` matches exactly priority 7 (as string comparison)
## Data structures
```rust
/// A compiled set of filter expressions applied to tickets.
///
/// Within the same key, patterns are ORed (any match passes the group).
/// Across different keys, groups are ANDed (all non-empty groups must pass).
pub struct TicketFilter {
/// Glob patterns for `status` (OR within this group).
pub status: Vec<String>,
/// Glob patterns for `ticket_type` (OR within this group).
pub ticket_type: Vec<String>,
/// Glob patterns for `priority` (OR within this group; matched against string repr).
pub priority: Vec<String>,
/// Glob patterns for `title` (OR within this group).
pub title: Vec<String>,
}
```
An empty `Vec` for a key means "no filter on that key" — matches everything.
## API
```rust
/// Parse a slice of "key=value" strings into a TicketFilter.
///
/// The key `type` maps to the `ticket_type` field.
/// Returns an error for unknown keys or malformed expressions (no '=').
pub fn parse_filters(args: &[String]) -> crate::store::Result<TicketFilter>
impl TicketFilter {
/// Returns true when all non-empty filter groups match this ticket.
/// An empty TicketFilter always returns true.
pub fn matches(&self, ticket: &crate::ticket::Ticket) -> bool;
/// Returns true when no filter groups are set (no-op filter).
pub fn is_empty(&self) -> bool;
/// Returns true when the user has provided at least one status pattern.
/// Used by cmd_list to detect whether to apply the implicit done-exclusion.
pub fn has_status_filter(&self) -> bool;
}
```
## Glob algorithm (no external crate)
Implement a private `fn glob_matches(pattern: &str, value: &str) -> bool`:
1. If pattern contains no `*`, require exact equality.
2. Split pattern on `*` into segments.
3. If pattern does NOT start with `*`, value must start with `segments[0]`.
4. If pattern does NOT end with `*`, value must end with `segments[last]`.
5. For each remaining segment, find it in the remaining suffix of value (left to right).
Advance past the match and continue. If any segment is not found, return false.
This handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`.
Case sensitivity:
- `status` and `type` patterns: case-insensitive (compare lowercase).
- `title` and `priority` patterns: case-sensitive.
## matches() logic
```
fn matches(&self, ticket) -> bool {
(self.status.is_empty() || self.status.iter().any(|p| glob_matches(p, status_str(&ticket.status))))
&& (self.ticket_type.is_empty() || self.ticket_type.iter().any(|p| glob_matches(p, ticket_type_str(&ticket.ticket_type))))
&& (self.priority.is_empty() || self.priority.iter().any(|p| glob_matches(p, &ticket.priority.to_string())))
&& (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title)))
}
```
Use `status_str` and `ticket_type_str` equivalents (can be private functions in filter.rs
or call into display, or duplicate the small match arms).
## Module registration
Add `mod filter;` to `src/main.rs` (or move to `lib.rs` if the project ever gains one).
The module is `pub(crate)`.
## Files touched
- `src/filter.rs` — new module
- `src/main.rs` — add `mod filter;`
- `src/tests.rs` — unit tests
## Unit tests to write (src/tests.rs)
- `parse_filters` rejects unknown keys with a descriptive error.
- `parse_filters` rejects a string with no `=`.
- `parse_filters` with `key=value=more` treats everything after first `=` as the value.
- `glob_matches("*", "anything")` → true.
- `glob_matches("*", "")` → true.
- `glob_matches("todo", "todo")` → true; `glob_matches("todo", "done")` → false.
- `glob_matches("*command*", "add command here")` → true.
- `glob_matches("*command*", "no match")` → false.
- `glob_matches("in_*", "in_progress")` → true.
- `glob_matches("in_*", "todo")` → false.
- `TicketFilter::matches` — two different keys AND correctly (both must match).
- `TicketFilter::matches` — same key OR correctly (either matches).
- `TicketFilter::matches` — empty filter matches everything.
- `TicketFilter::is_empty` — true when no filters, false when any filter set.
- `TicketFilter::has_status_filter` — true iff status vec is non-empty.

@ -1,11 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo:*)",
"Bash(git:*)",
"Bash(ls:*)",
"Edit",
"Write"
]
}
}

@ -1,12 +0,0 @@
---
name: triage
description: triage issues
---
* Read @docs @src @tests @PLAN.md @README.md and @CLAUDE.md for context before starting.
* Deeply understand each item before planning.
* Create `beans` beans for each item including your plan, breaking the problem down into smaller beans where it makes sense.
* Include relevant source files, documentation, and context about each bean so the work has the highest probability of success without additional context being required.
* If a file was passed to this skill, remove items from the file which now have a bean.
* Commit these beans in the `.beans` directory with a concise summary of each bean, probably using the title and bean IDs, and the file passed to this skill if one was passed.
* Do not implement any tickets.

@ -1,15 +0,0 @@
---
name: work
description: work on the highest priority thing
---
* If a ticket ID or description of a ticket is provided, look for that ticket.
* If no ticket/description is provided, use `beans list --json --ready` to choose the highest-priority unblocked bean to work on.
* Thoroughly investigate the relevant parts of the codebase to ensure you understand the problem and how to implement the solution as describe in the ticket.
* Implement the plan in the selected ticket.
* Once complete validate changes with cargo fmt, cargo check, cargo clippy, and cargo test.
* Iteratively fix warnings and errors then repeat the validation steps until everything is good.
* Update the ticket with the new status.
* Create additional tickets based on work that may have come up during this work.
* Add or Update docs including README.md and CLAUDE.md if relevant.
* Commit changes with a concise message and reference the ticket ID.

@ -1,184 +0,0 @@
@../CLAUDE.md
# CLAUDE.md — nbd
<working-directory>
All commands in this file are run from the `nbd/` directory.
</working-directory>
<project-overview>
`nbd` is a CLI tool for managing work tickets, primarily targeted at agent workflows. It is an independent Rust crate within the `vibed` mono-repo.
</project-overview>
<project-structure>
## Project Structure
```
nbd/
├── CLAUDE.md
├── PLAN.md # implementation plan and post-MVP roadmap
├── Cargo.toml
├── Cargo.lock
├── src/
│ ├── main.rs # CLI entry point; clap subcommand dispatch
│ ├── ticket.rs # Ticket struct, Status and TicketType enums
│ ├── store.rs # file I/O, directory traversal, CRUD
│ ├── display.rs # tabular and JSON output formatting
│ └── tests.rs # unit tests
├── tests/
│ └── integration.rs # integration tests using tempdir
├── docs/
│ ├── PLANNING.md # development phases and work logs
│ └── ARCHITECTURE.md # component overview and interactions
└── README.md
```
</project-structure>
<tech-stack>
## Tech Stack
- **Language:** Rust (edition 2021)
- **Async runtime:** `async-std`
- **CLI parsing:** `clap` (derive feature)
- **Serialization:** `serde` + `serde_json`
- **Test utilities:** `tempfile` (dev-dependency)
</tech-stack>
<data-model>
## Data Model
Tickets are stored as `.json` files in `.nbd/tickets/{id}.json`, where `id` is a 6-character hex string (e.g. `a3f9c2`). The `.nbd/` root is found by traversing up from cwd, like `git` finds `.git/`.
```
Ticket {
id: String // 6-char hex
title: String
body: String
priority: u8 // 0..=10, default 5
status: Status // Todo | InProgress | Done | Closed | Archived | Backlog, default Todo
dependencies: Vec<String> // Vec of ticket IDs, default []
ticket_type: TicketType // Project | Feature | Task | Bug, default Task
}
```
</data-model>
<cli-interface>
## CLI Interface
```sh
nbd init [--json]
nbd create --title "..." [--body "..."] [--priority 5] [--status todo|in_progress|done|closed|archived|backlog]
[--type task] [--deps id1,id2] [--json]
nbd read <id> [--json]
nbd list [--json]
nbd ready [--json]
nbd update <id> [--title "..."] [--body "..."] [--priority N]
[--status ...] [--type ...] [--deps ...] [--json]
nbd graph [<id>] [--filter KEY=VALUE ...] [--json]
```
`--json` is available on all commands for machine-readable output.
</cli-interface>
<module-responsibilities>
## Module Responsibilities
| Module | Responsibility |
|---|---|
| `main.rs` | `clap` CLI definition and subcommand dispatch only |
| `ticket.rs` | `Ticket` struct, enums, ID generation, validation |
| `store.rs` | directory traversal, file read/write, CRUD operations |
| `display.rs` | tabular formatting, JSON output, ASCII graph rendering |
| `graph.rs` | dependency graph computation (`TicketGraph`, edges, subtrees) |
| `filter.rs` | glob-pattern ticket filtering |
| `tests.rs` | unit tests for all modules |
</module-responsibilities>
<task-tracking>
## Task Tracking with beans
Use `beans` to track tasks for work on this project.
```sh
beans init # run once; creates .beans/
```
### Workflow
Always pass `--json` to every command. Use `jq` to parse output when needed.
**Before starting work:** Create a bean.
```sh
beans create --json "Add partial ID matching" --type feature --priority high --status todo
```
**When starting a task:** Update its status.
```sh
beans update --json <id> --status in-progress
```
**When done:** Mark it complete.
```sh
beans update --json <id> --status completed
```
**To see all beans ready to start:**
```sh
beans list --json --ready
```
**To see all beans:**
```sh
beans list --json
```
**To view a specific bean:**
```sh
beans show --json <id>
```
### Guidelines
- **Always use `--json`.** It gives structured, unambiguous output on every command.
- Create beans *before* starting non-trivial tasks, not after.
- Use `--blocked-by <id>` to express blockers — beans that must be done first.
- `--priority` choices: `critical`, `high`, `normal`, `low`, `deferred`.
- `--type` choices: `milestone`, `epic`, `feature`, `task`, `bug`.
</task-tracking>
<testing>
## Testing
- **Unit tests:** `src/tests.rs` — test each module in isolation; use `tempfile` for any file I/O
- **Integration tests:** `tests/integration.rs` — test full command flows (create → read → list → update) against a real temp directory; test directory traversal by running from a subdirectory
Target 80%+ code coverage.
</testing>

2465
nbd/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -1,28 +0,0 @@
[package]
name = "nbd"
version = "0.1.0"
edition = "2021"
description = "CLI tool for managing work tickets, targeted at agent workflows"
license = "MIT OR Apache-2.0"
[[bin]]
name = "nbd"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
libsql = "0.6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
ciborium = "0.2"
[dev-dependencies]
tempfile = "3"
[profile.release]
opt-level = "z"
lto = true
strip = true
codegen-units = 1

@ -1,179 +0,0 @@
# nbd — Implementation Plan
## Overview
`nbd` is a CLI tool for managing work tickets, primarily targeted at agent workflows. It stores tickets as JSON files in `.nbd/tickets/` in the project root (found by traversing up from cwd, like git).
**Crate location:** `vibed/nbd/`
**Language:** Rust (edition 2021)
**Key crates:** `clap`, `async-std`, `serde` / `serde_json`, `tempfile` (tests)
---
## Phase 1: Crate Scaffold
Set up the crate structure with no logic yet.
- [x] Create `nbd/Cargo.toml` with dependencies: `clap` (derive feature), `async-std`, `serde`, `serde_json`; dev-dependencies: `tempfile`
- [x] Add `[profile.release]` optimizations (`opt-level = "z"`, `lto = true`, `strip = true`, `codegen-units = 1`)
- [x] Create `nbd/src/main.rs``async-std` entry point, placeholder `fn main()`
- [x] Create `nbd/src/ticket.rs` — empty module
- [x] Create `nbd/src/store.rs` — empty module
- [x] Create `nbd/src/display.rs` — empty module
- [x] Create `nbd/src/tests.rs` — empty test module
- [x] Create `nbd/tests/integration.rs` — empty integration test file
- [x] Verify: `cargo check` passes
---
## Phase 2: Data Model (`ticket.rs`)
Define the core types.
- [x] Define `Status` enum: `Todo`, `InProgress`, `Done` (default: `Todo`)
- Derive: `Serialize`, `Deserialize`, `Debug`, `Clone`, `PartialEq`
- Serialize to/from lowercase strings (`"todo"`, `"in_progress"`, `"done"`)
- [x] Define `TicketType` enum: `Project`, `Feature`, `Task`, `Bug` (default: `Task`)
- Derive same traits
- Serialize to/from lowercase strings
- [x] Define `Ticket` struct:
```
id: String // 6-char hex, e.g. "a3f9c2"
title: String
body: String
priority: u8 // 0..=10, default 5
status: Status
dependencies: Vec<String> // Vec of ticket IDs
ticket_type: TicketType // field name avoids keyword collision
```
- Derive: `Serialize`, `Deserialize`, `Debug`, `Clone`
- [x] Implement `Ticket::new(id, title)` constructor with all defaults
- [x] Implement priority validation (error if > 10)
- [x] Implement ID generation: 3 random bytes → 6 hex chars (use `std::collections::hash_map::RandomState` or similar; no external crate needed for MVP)
- [x] Unit tests: serialization roundtrip, priority validation, ID format (6 hex chars, unique across N calls)
---
## Phase 3: Storage (`store.rs`)
File I/O and directory traversal using `async-std`.
- [x] Implement `find_nbd_root() -> Result<PathBuf>`: walk from cwd upward until `.nbd/` is found; error with helpful message if not found
- [x] Implement `find_nbd_root_from(start: &Path) -> Result<PathBuf>`: testable variant that accepts a starting path
- [x] Implement `tickets_dir(root: &Path) -> PathBuf`: returns `root/.nbd/tickets/`
- [x] Implement `ensure_tickets_dir(root: &Path) -> Result<()>`: creates `.nbd/tickets/` if missing (used only by `create`)
- [x] Implement `ticket_path(root: &Path, id: &str) -> PathBuf`: returns `.nbd/tickets/{id}.json`
- [x] Implement `write_ticket(root: &Path, ticket: &Ticket) -> Result<()>`: serialize to JSON, write file
- [x] Implement `read_ticket(root: &Path, id: &str) -> Result<Ticket>`: read file, deserialize; error if not found
- [x] Implement `list_tickets(root: &Path) -> Result<Vec<Ticket>>`: read all `*.json` from tickets dir, deserialize all, sort by priority descending
- [x] Unit tests: roundtrip write/read with tempdir, list returns all tickets, traversal finds `.nbd/` in grandparent dir
---
## Phase 4: Display (`display.rs`)
Output formatting.
- [x] Implement `print_ticket(ticket: &Ticket)`: full tabular display
```
ID: a3f9c2
Title: Fix login bug
Body: Users cannot log in with email addresses containing +
Priority: 8
Status: in_progress
Type: bug
Dependencies: b7d41e, c9e823
```
- [x] Implement `print_ticket_json(ticket: &Ticket)`: pretty-printed JSON to stdout
- [x] Implement `print_list(tickets: &[Ticket])`: short table
```
ID PRI TYPE STATUS TITLE
a3f9c2 8 bug in_progress Fix login bug
b7d41e 5 task todo Add rate limiting
```
- [x] Implement `print_list_json(tickets: &[Ticket])`: JSON array to stdout
- [x] Added `format_ticket`, `format_ticket_json`, `format_list`, `format_list_json` internal functions for testability
- [x] Unit tests: table output contains expected field values, JSON output is valid
---
## Phase 5: CLI Commands (`main.rs`)
Wire up `clap` subcommands to storage and display.
- [x] Define CLI structure with `clap` derive:
- Global flag: `--json` (all commands)
- Subcommand `create`: `--title` (required), `--body`, `--priority`, `--status`, `--type`, `--deps` (comma-separated IDs)
- Subcommand `read`: positional `<id>`
- Subcommand `list`: no args
- Subcommand `update`: positional `<id>`, same optional flags as `create`
- [x] Implement `cmd_create`: generate ID, validate deps exist, write ticket, print
- [x] Implement `cmd_read`: find ticket by ID, print
- [x] Implement `cmd_list`: list all tickets, print
- [x] Implement `cmd_update`: read existing ticket, merge only provided flags, write, print
- [x] Added `parse_status`, `parse_ticket_type`, `parse_deps`, `validate_deps` helpers
- [x] Fixed clippy warning in `store.rs`: `map_or(false, ...)``is_some_and(...)`
- [x] Integration tests (tempdir): create → read roundtrip, list shows created tickets, update merges correctly, traversal test (run from subdir), error on unknown ID, JSON flag tests, dep replacement test
---
## Phase 6: Documentation & Validation
- [x] Write `nbd/README.md`: what, how, run, test, license, Claude Code disclaimer
- [x] Write `nbd/docs/PLANNING.md`: this plan + work log
- [x] Write `nbd/docs/ARCHITECTURE.md`: module overview and interactions
- [x] Run validation in order: `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test`
- [x] Verified end-to-end:
```sh
cargo run -- create --title "Test ticket" --priority 7 --type bug
cargo run -- list
cargo run -- read <id>
cargo run -- update <id> --status in_progress
cargo run -- list --json
```
---
## Post-MVP
The following features are planned but excluded from the MVP:
- `nbd init` — explicit initialization command
- `nbd ready` — list tickets with no blockers
- `nbd archive` — mark tickets as Closed
- `nbd update` git-diff style +/- output
- Partial ID matching (`nbd read a3f` finding `a3f9c2`)
- SQLite cache for performance
- Nix flake for depending on `nbd` in other projects
### Multiple File Format Support
`nbd` will support multiple ticket file formats selectable via `--ftype=[json|md|toml|jsonb]` on `create` and `update`.
**Format detection** is by file extension: `.json`, `.md`, `.toml`, `.jsonb`. The store layer detects format from the file extension when reading; no format metadata is stored separately.
**Markdown format** (`.md`): The file body is the ticket `body` field. Metadata is stored as frontmatter:
- `---` delimiter → YAML frontmatter
- `+++` delimiter → TOML frontmatter
Example (YAML frontmatter):
```
---
id: a3f9c2
title: Fix login bug
priority: 8
status: in_progress
ticket_type: bug
dependencies: [b7d41e]
---
Long-form body text goes here. Supports full markdown.
```
**TOML format** (`.toml`): All fields in a flat TOML file; `body` is a multi-line string.
**Binary JSON** (`.jsonb`): Compact binary-encoded JSON (e.g. via CBOR or BSON; crate TBD at implementation time).
**Conversion via `nbd update --ftype`**: Reads the ticket in its current format, writes it in the new format, and deletes the old file. The ID and all field values are preserved.
**Crates to evaluate:** `serde_yaml`, `toml`, `ciborium` (CBOR) or `bson`.

@ -1,268 +0,0 @@
# nbd
A CLI tool for managing work tickets, primarily targeted at agent workflows.
Tickets are stored as JSON files in `.nbd/tickets/` inside the nearest ancestor
directory that contains a `.nbd/` folder, discovered by traversing upward from
the current working directory — just like `git` finds `.git/`.
## How it works
Each ticket is a JSON file named `{id}.json`, where `id` is a unique
6-character lowercase hex string (e.g. `a3f9c2`). Tickets carry:
| Field | Type | Default |
|---|---|---|
| `id` | 6-char hex string | auto-generated |
| `title` | string | *(required)* |
| `body` | string | `""` |
| `priority` | integer 010 | `5` |
| `status` | `todo` \| `in_progress` \| `done` \| `closed` \| `archived` \| `backlog` | `todo` |
| `ticket_type` | `project` \| `feature` \| `task` \| `bug` | `task` |
| `dependencies` | list of ticket IDs | `[]` |
Tickets can be stored in multiple formats, selected at creation time with
`--ftype`:
| Format | Extension | Description |
|---|---|---|
| `json` | `.json` | Pretty-printed JSON (default) |
| `md` | `.md` | Markdown body with TOML frontmatter |
| `toml` | `.toml` | TOML |
| `jsonb` | `.jsonb` | CBOR binary |
All commands accept `--json` for machine-readable output.
## Usage
### Initialise
Create the ticket store in your project root:
```sh
nbd init
```
Analogous to `git init` — safe to run multiple times.
### Create a ticket
```sh
nbd create --title "Fix login bug" --priority 8 --type bug
nbd create --title "Add rate limiting" --body "Protect public endpoints" --deps a3f9c2
nbd create --title "Long-form spec" --body "# Section\n\nDetails..." --ftype md
nbd create --title "Config ticket" --ftype toml
```
### Read a ticket
```sh
nbd read a3f9c2
nbd read a3f9c2 --json
```
### List all tickets
By default, `done`, `closed`, `archived`, and `backlog` tickets are excluded.
```sh
nbd list # todo + in_progress only (done/closed/archived/backlog excluded)
nbd list --all # all tickets including done, closed, archived, and backlog
nbd list --filter status=* # all tickets (alias for --all via filter)
nbd list --filter status=done # only completed tickets
nbd list --filter status=archived # only archived tickets (set by nbd archive)
nbd list --filter status=closed # only closed/cancelled tickets
nbd list --filter status=backlog # only backlog tickets
nbd list --filter type=bug # non-done, non-closed, non-archived, non-backlog bug tickets
nbd list --json
```
### Archive a ticket
Soft-delete a ticket by setting its status to `archived`. The file is preserved
on disk but hidden from normal listings.
```sh
nbd archive a3f9c2
nbd archive a3f9c2 --json
```
`nbd archive` is shorthand for `nbd update <id> --status archived`. Archived
tickets count as resolved for dependency purposes — any ticket that depends on
an archived ticket becomes unblocked.
The `closed` status is distinct from `archived`: use `closed` (via
`nbd update <id> --status closed`) for tickets that will not be completed
(cancelled, superseded, won't-fix). Both `archived` and `closed` are excluded
from normal listings and both count as resolved for dependencies.
### Update a ticket
Only the flags you supply are changed; all other fields retain their current
values. Use `--ftype` to convert to a different storage format (the old file
is removed automatically).
```sh
nbd update a3f9c2 --status in_progress
nbd update a3f9c2 --priority 9 --type bug
nbd update a3f9c2 --ftype md # convert to markdown format
```
### Find the next ticket to work on
Returns the single highest-priority ticket that is ready to work on — not done
and with all dependencies completed:
```sh
nbd next
nbd next --json
nbd next --filter type=bug # highest-priority ready bug
nbd next --filter priority=9 # highest-priority ready ticket at priority 9
```
Exits 0 even when no ready ticket exists (`{"next": null}` in JSON mode).
### Find all actionable tickets
List all tickets that are ready to work on — not done and with all dependencies
completed:
```sh
nbd ready
nbd ready --json
```
### Visualise dependencies
Draw an ASCII dependency graph showing which tickets block which others.
Roots (tickets with no dependencies) appear at the top; tickets that depend on
them are indented below using box-drawing characters:
```sh
nbd graph # full dependency forest
nbd graph <id> # subtree rooted at the given ticket
nbd graph --json # machine-readable adjacency list
nbd graph <id> --json # machine-readable subtree
nbd graph --filter type=bug # only bug tickets and their dependents
```
Example output:
```
a3f9c2 [done] Fix login bug
├── b7d41e [in_progress] Add rate limiting
│ └── c9e823 [todo] Write tests
└── d1f302 [done] Update docs
```
When a ticket appears in multiple subtrees it is rendered as `*` on its
second occurrence to keep the output finite. Edges in JSON output use
`{"from": <blocker>, "to": <blocked>}` — the blocking ticket is `from`.
### Print a CLAUDE.md snippet
Print a ready-to-paste section for adopting `nbd` in any project's CLAUDE.md:
```sh
nbd claude-md # raw markdown, suitable for redirect
nbd claude-md >> CLAUDE.md # append to your project's CLAUDE.md
nbd claude-md --json # machine-readable {"snippet": "..."}
```
The snippet is embedded in the binary at compile time, so it is always in sync
with the installed version of `nbd`.
### Migrate ticket files
Re-serialise all ticket files through the current schema. Use this after
upgrading `nbd` to remove stale fields, add new fields with defaults, and
normalise formatting.
```sh
nbd migrate
nbd migrate --dry-run # preview changes without writing
nbd migrate --json # machine-readable summary
```
## Installation
### Nix (recommended)
Run directly without installing, using the bundled Nix flake:
```sh
nix run github:elijah/vibed?dir=nbd -- --help
```
Or add to a Nix devShell:
```nix
{
inputs.nbd.url = "github:elijah/vibed?dir=nbd";
# then use nbd.packages.${system}.nbd in your devShell packages
}
```
Build locally from the `nbd/` directory:
```sh
nix build # builds the nbd binary
nix run -- init # run nbd init via nix run
```
### Cargo
```sh
cargo install --path .
```
## Running
```sh
# From the nbd/ directory
cargo run -- --version # prints e.g. nbd 0.1.0+7e311d6
cargo run -- init
cargo run -- create --title "Test ticket" --priority 7 --type bug
cargo run -- list
cargo run -- list --all
cargo run -- ready
cargo run -- read <id>
cargo run -- update <id> --status in_progress
cargo run -- archive <id>
cargo run -- list --json
cargo run -- graph
cargo run -- graph <id>
cargo run -- graph --json
cargo run -- claude-md
cargo run -- claude-md --json
```
## Testing
```sh
cargo test
```
Unit tests live in `src/tests.rs`. Integration tests (full command flows against
a temporary directory) live in `tests/integration.rs`.
## Development
Run these commands in order before committing:
```sh
cargo fmt
cargo check
cargo clippy
cargo test
```
## License
Dual-licensed under [Apache License, Version 2.0](../LICENSE-APACHE) and
[MIT License](../LICENSE-MIT), consistent with the rest of the `vibed` mono-repo.
---
*This software was written with [Claude Code](https://claude.com/claude-code)
using the claude-sonnet-4-6 model.*

@ -1 +0,0 @@
# Work to be ticketed

@ -1,31 +0,0 @@
//! Build script for `nbd`.
//!
//! Captures the short git SHA at compile time and emits it as the
//! `GIT_SHORT_SHA` environment variable, making it available via
//! `env!("GIT_SHORT_SHA")` in the crate source.
//!
//! Falls back to `"unknown"` when git is unavailable (e.g. a clean Nix
//! sandbox build where `.git/` is not present).
fn main() {
// Capture the short git SHA at build time.
let sha = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(o.stdout)
} else {
None
}
})
.and_then(|b| String::from_utf8(b).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=GIT_SHORT_SHA={sha}");
// Re-run whenever HEAD or any ref changes (new commits, branch switches).
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
}

@ -1,127 +0,0 @@
# nbd — Architecture
## Overview
`nbd` is a single-binary CLI tool with no background service. All state lives in
`.nbd/tickets/` inside the project directory tree. The binary is structured as
four modules, each with a single responsibility:
```
src/
├── main.rs CLI definition and command dispatch
├── ticket.rs Data model, ID generation, priority validation
├── store.rs File I/O, directory traversal, CRUD
└── display.rs Human-readable and JSON output formatting
```
## Module Responsibilities
### `main.rs` — CLI entry point
Owns the `clap` struct definitions (`Cli`, `Commands`) and the top-level `main`
function. Delegates all business logic to command handlers (`cmd_create`,
`cmd_read`, `cmd_list`, `cmd_update`). The handlers are thin: they call helpers
from `store`, `ticket`, and `display`, then return.
Parsing helpers (`parse_status`, `parse_ticket_type`, `parse_deps`) live here
because they translate raw CLI strings into typed values used only by the command
handlers.
### `ticket.rs` — Data model
Defines the canonical in-memory representation of a ticket:
- `Ticket` struct — all fields
- `Status` enum — `Todo | InProgress | Done`
- `TicketType` enum — `Project | Feature | Task | Bug`
Provides two free functions:
- `generate_id() -> String` — uses `RandomState` (seeded with OS entropy) to
produce a 6-character lowercase hex string. No external randomness crate needed.
- `validate_priority(u8) -> Result<(), String>` — enforces the 010 range.
The enums derive `serde`'s `Serialize`/`Deserialize` with `#[serde(rename_all)]`
so the JSON representation uses lowercase strings (`"in_progress"`, `"bug"`, …).
### `store.rs` — File I/O
All filesystem operations are in this module. Uses `async-std` for async I/O
throughout.
**Directory discovery:**
```
find_nbd_root()
└─ find_nbd_root_from(cwd)
Walk parent dirs until .nbd/ is found
```
`find_nbd_root_from` accepts an explicit starting path to make it testable
without changing the process's working directory.
**CRUD functions:**
| Function | Operation |
|---|---|
| `ensure_tickets_dir(root)` | Create `.nbd/tickets/` if missing |
| `write_ticket(root, ticket)` | Serialise → pretty JSON → write file |
| `read_ticket(root, id)` | Read file → deserialise; friendly error if not found |
| `list_tickets(root)` | Read all `*.json` → sort by priority desc |
**Error type:** `store::Result<T>` is aliased to
`Result<T, Box<dyn Error + Send + Sync>>`, allowing `?` to propagate both
`io::Error` and `serde_json::Error` without explicit wrapping.
### `display.rs` — Output formatting
Two output modes:
- **Tabular** — human-readable keyvalue block (single ticket) or column-aligned
table (list). Column widths are compile-time constants.
- **JSON** — delegates to `serde_json::to_string_pretty`.
Each public surface is split into a `format_*` function (returns `String`,
testable) and a `print_*` function (writes to stdout via `println!`).
## Data Flow
```
CLI args (clap)
dispatch() [main.rs]
├── parse_* helpers ──► typed values
├── store::find_nbd_root() ──► PathBuf
├── store::read_ticket() ──► Ticket
├── store::list_tickets() ──► Vec<Ticket>
├── store::write_ticket() ──► ()
└── display::print_ticket() ──► stdout
display::print_list() ──► stdout
```
## Storage Layout
```
<project-root>/
└── .nbd/
└── tickets/
├── a3f9c2.json
├── b7d41e.json
└── ...
```
Each file is a pretty-printed JSON object with the fields of `Ticket`. File name
equals the ticket's `id` field with a `.json` extension.
## Testing Strategy
- **Unit tests** (`src/tests.rs`): exercise `ticket.rs`, `store.rs`, and
`display.rs` in isolation using `tempfile::TempDir` for any file I/O.
- **Integration tests** (`tests/integration.rs`): drive full command flows
(create → read → list → update) through `cmd_*` functions against a temporary
directory. Include a directory-traversal test that invokes commands from a
nested subdirectory.

@ -1,79 +0,0 @@
# nbd — Planning & Work Log
## Development Phases
### Phase 1: Crate Scaffold ✅
Set up the crate structure with no logic yet.
- Created `Cargo.toml` with `clap` (derive), `async-std`, `serde`, `serde_json`; dev-dep `tempfile`
- Added `[profile.release]` optimisations (`opt-level = "z"`, `lto`, `strip`, `codegen-units = 1`)
- Created `src/main.rs``async-std` entry point with placeholder `main`
- Created empty modules: `ticket.rs`, `store.rs`, `display.rs`, `tests.rs`
- Created `tests/integration.rs`
- Verified `cargo check` passed
### Phase 2: Data Model (`ticket.rs`) ✅
- Defined `Status` enum (`Todo`, `InProgress`, `Done`; default `Todo`)
- Serialises to lowercase snake_case: `"todo"`, `"in_progress"`, `"done"`
- Defined `TicketType` enum (`Project`, `Feature`, `Task`, `Bug`; default `Task`)
- Serialises to lowercase: `"project"`, `"feature"`, `"task"`, `"bug"`
- Defined `Ticket` struct with all required fields
- Implemented `Ticket::new(id, title)` with defaults
- Implemented `validate_priority(priority)` — errors if > 10
- Implemented `generate_id()` — 3 random bytes → 6 hex chars via `RandomState`
- Unit tests: serialisation roundtrip, priority validation, ID format and uniqueness
### Phase 3: Storage (`store.rs`) ✅
- Implemented `find_nbd_root_from(start)` — walks up from `start` to find `.nbd/`
- Implemented `find_nbd_root()` — starts from `std::env::current_dir()`
- Implemented `tickets_dir(root)` — pure path computation
- Implemented `ensure_tickets_dir(root)` — creates `.nbd/tickets/` if missing
- Implemented `ticket_path(root, id)` — pure path computation
- Implemented `write_ticket(root, ticket)` — serialise and write JSON file
- Implemented `read_ticket(root, id)` — read and deserialise; descriptive error if not found
- Implemented `list_tickets(root)` — read all `*.json`, sort by priority descending
- Unit tests: write/read roundtrip, list returns all tickets, traversal from grandparent dir
### Phase 4: Display (`display.rs`) ✅
- Implemented `format_ticket(ticket)` / `print_ticket(ticket)` — full tabular view
- Implemented `format_ticket_json(ticket)` / `print_ticket_json(ticket)` — pretty JSON
- Implemented `format_list(tickets)` / `print_list(tickets)` — short summary table
- Implemented `format_list_json(tickets)` / `print_list_json(tickets)` — JSON array
- `format_*` functions return `String` for testability; `print_*` write to stdout
- Unit tests: table output contains expected field values, JSON is valid
### Phase 5: CLI Commands (`main.rs`) ✅
- Defined `Cli` struct with global `--json` flag and `Commands` enum
- Subcommands: `create`, `read`, `list`, `update`
- Implemented `cmd_create`, `cmd_read`, `cmd_list`, `cmd_update`
- Added `parse_status`, `parse_ticket_type`, `parse_deps`, `validate_deps` helpers
- Fixed clippy warning: `map_or(false, ...)``is_some_and(...)`
- Integration tests: create→read roundtrip, list, update merge, traversal from subdir,
error on unknown ID, `--json` flag, dep replacement
### Phase 6: Documentation & Validation ✅
- Wrote `README.md` — what, how, usage, testing, license, Claude Code disclaimer
- Wrote `docs/PLANNING.md` (this file) — phases and work log
- Wrote `docs/ARCHITECTURE.md` — module overview and interactions
- Ran `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test` — all clean
---
## Post-MVP Roadmap
See [`PLAN.md`](../PLAN.md) for the full list of planned post-MVP features, including:
- `nbd init` — explicit initialisation command
- `nbd ready` — list tickets with no blockers
- `nbd archive` — mark tickets as Closed
- `nbd update` git-diff-style output
- Partial ID matching
- SQLite cache
- Nix flake
- Multiple file format support (`.md`, `.toml`, `.jsonb`)

@ -1,48 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1771369470,
"narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0182a361324364ae3f436a63005877674cf45efb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1771816254,
"narHash": "sha256-vkp3iTF6QmHMvL+34DI93IiMPjS2lqcMlA1fl7nXVsQ=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "085bdbf5dde5477538e4c87d1684b6c6df56c0ad",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

@ -1,98 +0,0 @@
{
description = "nbd CLI tool for managing work tickets, targeted at agent workflows";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
rust-overlay,
}:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems =
f:
nixpkgs.lib.genAttrs systems (
system:
f {
inherit system;
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
}
);
in
{
packages = forAllSystems (
{ pkgs, system }:
{
# The nbd CLI package, built with rustPlatform.buildRustPackage.
nbd = pkgs.rustPlatform.buildRustPackage {
pname = "nbd";
version = "0.1.0";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
meta = {
description = "CLI tool for managing work tickets, targeted at agent workflows";
license = with pkgs.lib.licenses; [
asl20
mit
];
mainProgram = "nbd";
};
};
# Make nbd the default package so `nix build` works without specifying an attribute.
default = self.packages.${system}.nbd;
}
);
apps = forAllSystems (
{ system, ... }:
{
# Run nbd directly with `nix run .#nbd`.
nbd = {
type = "app";
program = "${self.packages.${system}.nbd}/bin/nbd";
};
# Make nbd the default app so `nix run` works without specifying an attribute.
default = self.apps.${system}.nbd;
}
);
devShells = forAllSystems (
{ pkgs, system }:
{
# Development shell with stable Rust toolchain and nbd binary.
default = pkgs.mkShell {
packages = [
# Stable Rust toolchain: compiler, cargo, rustfmt, clippy.
pkgs.rust-bin.stable.latest.default
# The built nbd binary, available as `nbd` in the shell.
self.packages.${system}.nbd
];
};
}
);
};
}

@ -1,11 +0,0 @@
## Task Tracking with beans
This project uses **beans** for issue tracking. Run `beans prime` to get the full usage guide.
```sh
# Initialise (run once per project directory)
beans init
# Get full usage guide and instructions for this project
beans prime
```

@ -1,473 +0,0 @@
//! Output formatting for tickets.
//!
//! Provides functions for rendering tickets and ticket lists as human-readable
//! tables or machine-readable JSON, depending on the caller's preference.
//!
//! Each `print_*` function writes directly to stdout and is intended for use
//! in command handlers. The corresponding `format_*` functions return a
//! `String` and are provided primarily for testing and composition.
use std::collections::HashSet;
use serde::Serialize;
use crate::graph::TicketGraph;
use crate::store::MigrateReport;
use crate::ticket::{Status, Ticket, TicketType};
// ── Column widths for the summary list table ─────────────────────────────────
/// Width of the ID column (6-char hex + padding).
const COL_ID: usize = 9;
/// Width of the priority column.
const COL_PRI: usize = 5;
/// Width of the ticket-type column ("project" = 7 chars, +2 padding).
const COL_TYPE: usize = 9;
/// Width of the status column ("in_progress" = 11 chars, +2 padding).
const COL_STATUS: usize = 13;
/// Width of each label in the diff view ("dependencies:" = 13 + 1 space).
const LABEL_WIDTH: usize = 14;
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Ticket metadata serialised into the TOML frontmatter block for display.
///
/// Mirrors the on-disk `MarkdownFrontmatter` in `store.rs` but adds `id` as
/// the first field so that human-readable output is self-contained.
#[derive(Serialize)]
struct DisplayFrontmatter<'a> {
id: &'a str,
title: &'a str,
priority: u8,
status: &'a Status,
ticket_type: &'a TicketType,
dependencies: &'a [String],
}
/// Return the canonical display string for a [`Status`] variant.
///
/// The strings match the serde serialisation: `"todo"`, `"in_progress"`,
/// `"done"`, etc.
fn status_str(status: &Status) -> &'static str {
match status {
Status::Todo => "todo",
Status::InProgress => "in_progress",
Status::Done => "done",
Status::Closed => "closed",
Status::Archived => "archived",
Status::Backlog => "backlog",
}
}
/// Return the canonical display string for a [`TicketType`] variant.
///
/// The strings match the serde serialisation: `"project"`, `"feature"`,
/// `"task"`, `"bug"`.
fn ticket_type_str(ticket_type: &TicketType) -> &'static str {
match ticket_type {
TicketType::Project => "project",
TicketType::Feature => "feature",
TicketType::Task => "task",
TicketType::Bug => "bug",
}
}
// ── Public formatting functions ───────────────────────────────────────────────
/// Format a single ticket as a TOML-frontmatter markdown document.
///
/// The output mirrors the `.md` file format used on disk, with `id` added as
/// the first frontmatter key so the output is self-contained. The ticket body
/// follows the closing `+++` delimiter.
///
/// ```text
/// +++
/// id = "a3f9c2"
/// title = "Fix login bug"
/// priority = 8
/// status = "in_progress"
/// ticket_type = "bug"
/// dependencies = ["b7d41e", "c9e823"]
/// +++
/// Users cannot log in with email addresses containing +
/// ```
pub fn format_ticket(ticket: &Ticket) -> String {
let fm = DisplayFrontmatter {
id: &ticket.id,
title: &ticket.title,
priority: ticket.priority,
status: &ticket.status,
ticket_type: &ticket.ticket_type,
dependencies: &ticket.dependencies,
};
let toml_str = toml::to_string(&fm).expect("frontmatter serialisation must not fail");
format!("+++\n{toml_str}+++\n{}", ticket.body)
}
/// Print a full tabular representation of a single ticket to stdout.
pub fn print_ticket(ticket: &Ticket) {
println!("{}", format_ticket(ticket));
}
/// Serialise a ticket as a [`serde_json::Value`], explicitly inserting the
/// `id` field at the front.
///
/// [`crate::ticket::Ticket::id`] is annotated with `#[serde(skip)]` so that
/// the stored `.json` files do not duplicate the filename stem. However,
/// CLI `--json` output should include `id` so that consumers have all the
/// information in one object. This helper re-inserts it into the serialised
/// value.
pub(crate) fn ticket_to_json_value(ticket: &Ticket) -> serde_json::Value {
let mut value = serde_json::to_value(ticket).expect("ticket serialisation must not fail");
// Re-insert id at the front of the object for ergonomic CLI output.
if let serde_json::Value::Object(ref mut map) = value {
// `serde_json::Map` preserves insertion order; insert id first by
// rebuilding the map with id prepended.
let old_map = std::mem::take(map);
map.insert(
"id".to_string(),
serde_json::Value::String(ticket.id.clone()),
);
map.extend(old_map);
}
value
}
/// Format a single ticket as a pretty-printed JSON object.
///
/// The output is suitable for piping or machine consumption. The `id` field
/// is included even though it is not stored in the ticket's JSON file.
pub fn format_ticket_json(ticket: &Ticket) -> String {
let value = ticket_to_json_value(ticket);
serde_json::to_string_pretty(&value).expect("ticket serialisation must not fail")
}
/// Print a single ticket as pretty-printed JSON to stdout.
pub fn print_ticket_json(ticket: &Ticket) {
println!("{}", format_ticket_json(ticket));
}
/// Format a slice of tickets as a short summary table with a header row.
///
/// Columns are `ID`, `PRI`, `TYPE`, `STATUS`, and `TITLE`, each left-aligned
/// to a fixed width. Rows are presented in the order given — callers are
/// responsible for sorting before passing the slice. An empty slice still
/// produces the header row.
///
/// ```text
/// ID PRI TYPE STATUS TITLE
/// a3f9c2 8 bug in_progress Fix login bug
/// b7d41e 5 task todo Add rate limiting
/// ```
pub fn format_list(tickets: &[Ticket]) -> String {
let mut out = format!(
"{:<width_id$}{:<width_pri$}{:<width_type$}{:<width_status$}{}",
"ID",
"PRI",
"TYPE",
"STATUS",
"TITLE",
width_id = COL_ID,
width_pri = COL_PRI,
width_type = COL_TYPE,
width_status = COL_STATUS,
);
for ticket in tickets {
out.push('\n');
out.push_str(&format!(
"{:<width_id$}{:<width_pri$}{:<width_type$}{:<width_status$}{}",
ticket.id,
ticket.priority,
ticket_type_str(&ticket.ticket_type),
status_str(&ticket.status),
ticket.title,
width_id = COL_ID,
width_pri = COL_PRI,
width_type = COL_TYPE,
width_status = COL_STATUS,
));
}
out
}
/// Print a short summary table of multiple tickets to stdout.
pub fn print_list(tickets: &[Ticket]) {
println!("{}", format_list(tickets));
}
/// Format a slice of tickets as a pretty-printed JSON array.
///
/// Each object includes an `id` field even though it is not stored in the
/// individual ticket files on disk.
pub fn format_list_json(tickets: &[Ticket]) -> String {
let values: Vec<serde_json::Value> = tickets.iter().map(ticket_to_json_value).collect();
serde_json::to_string_pretty(&values).expect("ticket list serialisation must not fail")
}
/// Print a ticket list as a JSON array to stdout.
pub fn print_list_json(tickets: &[Ticket]) {
println!("{}", format_list_json(tickets));
}
/// Format a [`MigrateReport`] as a human-readable summary.
///
/// ```text
/// Migrated 3 tickets.
/// Current 5 tickets (already up to date).
/// Errors 1 ticket could not be migrated:
/// bad_ticket.json: trailing comma at line 4
/// ```
pub fn format_migrate_report(report: &MigrateReport) -> String {
let mut out = format!(
"Migrated {} ticket{}.\nCurrent {} ticket{} (already up to date).",
report.updated,
if report.updated == 1 { "" } else { "s" },
report.already_current,
if report.already_current == 1 { "" } else { "s" },
);
if report.skipped > 0 {
out.push_str(&format!(
"\nSkipped {} ticket{} (did not match filter).",
report.skipped,
if report.skipped == 1 { "" } else { "s" },
));
}
if !report.errors.is_empty() {
out.push_str(&format!(
"\nErrors {} ticket{} could not be migrated:",
report.errors.len(),
if report.errors.len() == 1 { "" } else { "s" },
));
for (filename, msg) in &report.errors {
out.push_str(&format!("\n {filename}: {msg}"));
}
}
out
}
/// Print a [`MigrateReport`] as a human-readable summary to stdout.
pub fn print_migrate_report(report: &MigrateReport) {
println!("{}", format_migrate_report(report));
}
/// Format a [`MigrateReport`] as a JSON object.
///
/// The JSON has three keys: `updated`, `already_current`, and `errors`.
/// `errors` is an array of objects with `filename` and `message` fields.
pub fn format_migrate_report_json(report: &MigrateReport) -> String {
let errors: Vec<serde_json::Value> = report
.errors
.iter()
.map(|(filename, msg)| {
serde_json::json!({
"filename": filename,
"message": msg,
})
})
.collect();
let value = serde_json::json!({
"updated": report.updated,
"already_current": report.already_current,
"skipped": report.skipped,
"errors": errors,
});
serde_json::to_string_pretty(&value).expect("migrate report serialisation must not fail")
}
/// Print a [`MigrateReport`] as a JSON object to stdout.
pub fn print_migrate_report_json(report: &MigrateReport) {
println!("{}", format_migrate_report_json(report));
}
/// Format a git-diff-style `- old / + new` summary of what changed between
/// two [`Ticket`] snapshots.
///
/// Each field that differs produces two lines:
///
/// ```text
/// - status: todo
/// + status: in_progress
/// ```
///
/// Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, and
/// `dependencies`. The `id` field is never shown because it cannot change.
///
/// Returns `"(no changes)"` when every compared field is identical.
pub fn format_diff(old: &Ticket, new: &Ticket) -> String {
let mut lines: Vec<String> = Vec::new();
macro_rules! diff_field {
($label:expr, $old_val:expr, $new_val:expr) => {{
let old_s: String = $old_val;
let new_s: String = $new_val;
if old_s != new_s {
lines.push(format!("- {:<LABEL_WIDTH$}{}", $label, old_s));
lines.push(format!("+ {:<LABEL_WIDTH$}{}", $label, new_s));
}
}};
}
diff_field!("title:", old.title.clone(), new.title.clone());
diff_field!("body:", old.body.clone(), new.body.clone());
diff_field!(
"priority:",
old.priority.to_string(),
new.priority.to_string()
);
diff_field!(
"status:",
status_str(&old.status).to_string(),
status_str(&new.status).to_string()
);
diff_field!(
"type:",
ticket_type_str(&old.ticket_type).to_string(),
ticket_type_str(&new.ticket_type).to_string()
);
diff_field!(
"dependencies:",
old.dependencies.join(", "),
new.dependencies.join(", ")
);
if lines.is_empty() {
"(no changes)".to_string()
} else {
lines.join("\n")
}
}
/// Print a git-diff-style summary of what changed between two tickets to stdout.
pub fn print_diff(old: &Ticket, new: &Ticket) {
println!("{}", format_diff(old, new));
}
// ── Graph rendering ───────────────────────────────────────────────────────────
/// Format the full dependency forest as an ASCII tree string.
///
/// Roots (tickets that no other ticket depends on — top-level goals) appear
/// at column 0, sorted by priority descending. Each root's dependencies
/// (prerequisites it needs completed first) are indented below using
/// box-drawing characters:
///
/// ```text
/// a3f9c2 [todo] Fix login bug
/// ├── b7d41e [in_progress] Add rate limiting
/// │ └── c9e823 [todo] Write tests
/// └── d1f302 [done] Update docs
/// e4a781 [todo] New feature (no prereqs)
/// ```
///
/// Nodes that appear in multiple subtrees are marked `*` on subsequent
/// occurrences rather than looping forever.
pub fn format_graph(graph: &TicketGraph<'_>) -> String {
let mut out = String::new();
let mut visited: HashSet<String> = HashSet::new();
for root in graph.roots() {
render_node(graph, &root.id, "", "", "", &mut visited, &mut out);
}
out
}
/// Print the full dependency forest to stdout.
pub fn print_graph(graph: &TicketGraph<'_>) {
println!("{}", format_graph(graph));
}
/// Format the subtree rooted at `root_id` as an ASCII tree string.
///
/// Renders `root_id` and every ticket it transitively depends on
/// (via dependency edges), using the same box-drawing format as
/// [`format_graph`]. Returns an empty string when `root_id` is not in the
/// graph.
pub fn format_subtree(graph: &TicketGraph<'_>, root_id: &str) -> String {
let mut out = String::new();
let mut visited: HashSet<String> = HashSet::new();
if graph.get_node(root_id).is_some() {
render_node(graph, root_id, "", "", "", &mut visited, &mut out);
}
out
}
/// Print the subtree rooted at `root_id` to stdout.
pub fn print_subtree(graph: &TicketGraph<'_>, root_id: &str) {
println!("{}", format_subtree(graph, root_id));
}
// ── Internal graph helpers ────────────────────────────────────────────────────
/// Recursively render a single node and its dependencies into `out`.
///
/// Parameters:
/// - `prefix` — the indentation printed before `connector` on this node's line.
/// This is the `child_base` that the parent passed down.
/// - `connector` — the box-drawing connector for this node (`""` for roots,
/// `"├── "` or `"└── "` for children).
/// - `child_base` — the base prefix handed to this node's *children* (it
/// becomes their `prefix`). It accumulates one more level of `│ ` or
/// ` ` with each descent.
///
/// When a node ID is already in `visited`, it is rendered as `*` and
/// the recursion stops, preventing infinite loops in cyclic or shared data.
fn render_node(
graph: &TicketGraph<'_>,
id: &str,
prefix: &str,
connector: &str,
child_base: &str,
visited: &mut HashSet<String>,
out: &mut String,
) {
// Already-visited detection: this node appears elsewhere in the tree.
if visited.contains(id) {
append_line(out, &format!("{prefix}{connector}{id} *"));
return;
}
visited.insert(id.to_string());
// Extract the node data we need, cloning so we can drop the borrow before
// recursing (the recursive call needs a mutable `visited`).
let (status_s, title, dependencies) = match graph.get_node(id) {
Some(node) => {
let s = status_str(&node.ticket.status);
let t = node.ticket.title.clone();
let d: Vec<String> = node.dependencies.iter().map(|s| s.to_string()).collect();
(s, t, d)
}
None => return,
};
append_line(
out,
&format!("{prefix}{connector}{id} [{status_s}] {title}"),
);
let n = dependencies.len();
for (i, dep_id) in dependencies.iter().enumerate() {
let is_last = i == n - 1;
// The child's connector on its own line.
let child_connector = if is_last { "└── " } else { "├── " };
// The grandchildren's base: extend child_base by one more level.
let grandchild_base = format!("{child_base}{}", if is_last { " " } else { "│ " });
render_node(
graph,
dep_id,
child_base,
child_connector,
&grandchild_base,
visited,
out,
);
}
}
/// Append `line` to `out`, preceded by a newline if `out` is non-empty.
fn append_line(out: &mut String, line: &str) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
}

@ -1,267 +0,0 @@
//! Ticket filtering with glob-pattern support.
//!
//! [`TicketFilter`] holds per-field glob patterns compiled from `key=value`
//! expressions. Patterns within the same field are ORed; patterns across
//! different fields are ANDed.
//!
//! # Example
//!
//! ```rust,ignore
//! let args = vec!["status=todo".to_string(), "type=bug".to_string()];
//! let filter = parse_filters(&args).unwrap();
//! assert!(filter.matches(&some_todo_bug_ticket));
//! ```
use crate::store::Result;
use crate::ticket::{Status, Ticket, TicketType};
// ── String helpers ─────────────────────────────────────────────────────────
/// Return the canonical lowercase display string for a [`Status`] variant.
fn status_str(status: &Status) -> &'static str {
match status {
Status::Todo => "todo",
Status::InProgress => "in_progress",
Status::Done => "done",
Status::Closed => "closed",
Status::Archived => "archived",
Status::Backlog => "backlog",
}
}
/// Return the canonical lowercase display string for a [`TicketType`] variant.
fn ticket_type_str(ticket_type: &TicketType) -> &'static str {
match ticket_type {
TicketType::Project => "project",
TicketType::Feature => "feature",
TicketType::Task => "task",
TicketType::Bug => "bug",
}
}
// ── Glob matching ─────────────────────────────────────────────────────────
/// Return `true` when `value` matches the glob `pattern`.
///
/// `*` is the only supported wildcard and it matches any sequence of
/// characters, including the empty sequence. All other characters are
/// matched literally.
///
/// The algorithm:
/// 1. If `pattern` contains no `*`, require exact equality.
/// 2. Split `pattern` on `*` into segments.
/// 3. If `pattern` does **not** start with `*`, `value` must start with the
/// first segment; advance past it.
/// 4. If `pattern` does **not** end with `*`, `value` must end with the last
/// segment; trim it.
/// 5. For each remaining middle segment, find it left-to-right in the
/// remaining string, advancing past the match. If any segment is absent,
/// return `false`.
///
/// Handles: `*`, `foo`, `foo*`, `*foo`, `*foo*`, `foo*bar`, `*foo*bar*`.
pub(crate) fn glob_matches(pattern: &str, value: &str) -> bool {
if !pattern.contains('*') {
return pattern == value;
}
let segments: Vec<&str> = pattern.split('*').collect();
let n = segments.len();
let mut remaining = value;
// Anchored prefix: when pattern does not start with '*', segments[0] is
// non-empty and value must begin with it.
let start_idx = if !segments[0].is_empty() {
match remaining.strip_prefix(segments[0]) {
Some(rest) => {
remaining = rest;
1
}
None => return false,
}
} else {
1 // skip the empty string before the leading '*'
};
// Anchored suffix: when pattern does not end with '*', segments[n-1] is
// non-empty and the remaining string must end with it.
let end_idx = if !segments[n - 1].is_empty() {
match remaining.strip_suffix(segments[n - 1]) {
Some(prefix) => {
remaining = prefix;
n - 1
}
None => return false,
}
} else {
n - 1 // skip the empty string after the trailing '*'
};
// Find each middle segment left-to-right inside the remaining substring.
for seg in &segments[start_idx..end_idx] {
if seg.is_empty() {
continue; // consecutive '*'s impose no additional constraint
}
match remaining.find(seg) {
Some(pos) => remaining = &remaining[pos + seg.len()..],
None => return false,
}
}
true
}
// ── TicketFilter ──────────────────────────────────────────────────────────
/// A compiled set of filter expressions applied to tickets.
///
/// Within the same key, patterns are ORed (any match passes the group).
/// Across different keys, groups are ANDed (all non-empty groups must pass).
///
/// An empty [`TicketFilter`] (all `Vec`s empty) matches every ticket.
///
/// Construct with [`parse_filters`].
#[derive(Debug, Default, Clone)]
pub struct TicketFilter {
/// Glob patterns matched against the ticket's `status` field (OR group).
/// Matching is case-insensitive.
pub status: Vec<String>,
/// Glob patterns matched against the ticket's `ticket_type` field (OR group).
/// Matching is case-insensitive.
pub ticket_type: Vec<String>,
/// Glob patterns matched against the string representation of `priority`
/// (OR group). Matching is case-sensitive.
pub priority: Vec<String>,
/// Glob patterns matched against the ticket's `title` field (OR group).
/// Matching is case-sensitive.
pub title: Vec<String>,
}
impl TicketFilter {
/// Returns `true` when all non-empty filter groups match `ticket`.
///
/// An empty [`TicketFilter`] always returns `true`.
pub fn matches(&self, ticket: &Ticket) -> bool {
let status_val = status_str(&ticket.status);
let type_val = ticket_type_str(&ticket.ticket_type);
let priority_val = ticket.priority.to_string();
// status and ticket_type comparisons are case-insensitive: lower-case
// the pattern before matching against the already-lowercase value.
(self.status.is_empty()
|| self
.status
.iter()
.any(|p| glob_matches(&p.to_lowercase(), status_val)))
&& (self.ticket_type.is_empty()
|| self
.ticket_type
.iter()
.any(|p| glob_matches(&p.to_lowercase(), type_val)))
// priority and title comparisons are case-sensitive.
&& (self.priority.is_empty()
|| self
.priority
.iter()
.any(|p| glob_matches(p, &priority_val)))
&& (self.title.is_empty()
|| self
.title
.iter()
.any(|p| glob_matches(p, &ticket.title)))
}
/// Returns `true` when no filter groups are set (no-op filter).
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.status.is_empty()
&& self.ticket_type.is_empty()
&& self.priority.is_empty()
&& self.title.is_empty()
}
/// Returns `true` when the caller has provided at least one status pattern.
///
/// Used by `cmd_list` to detect whether to apply the implicit
/// done-exclusion heuristic.
pub fn has_status_filter(&self) -> bool {
!self.status.is_empty()
}
/// Returns `true` if the ticket's status matches any of the status patterns.
///
/// Only meaningful when [`has_status_filter`] returns `true`. Matching
/// is case-insensitive.
///
/// [`has_status_filter`]: TicketFilter::has_status_filter
pub fn matches_status(&self, ticket: &Ticket) -> bool {
let status_val = status_str(&ticket.status);
self.status
.iter()
.any(|p| glob_matches(&p.to_lowercase(), status_val))
}
/// Returns `true` if the ticket matches all non-status filter groups
/// (type, priority, title). The status group is excluded so callers can
/// handle it separately.
///
/// An empty filter always returns `true`.
pub fn matches_except_status(&self, ticket: &Ticket) -> bool {
let type_val = ticket_type_str(&ticket.ticket_type);
let priority_val = ticket.priority.to_string();
(self.ticket_type.is_empty()
|| self
.ticket_type
.iter()
.any(|p| glob_matches(&p.to_lowercase(), type_val)))
&& (self.priority.is_empty()
|| self.priority.iter().any(|p| glob_matches(p, &priority_val)))
&& (self.title.is_empty() || self.title.iter().any(|p| glob_matches(p, &ticket.title)))
}
}
// ── Parsing ───────────────────────────────────────────────────────────────
/// Parse a slice of `"key=value"` strings into a [`TicketFilter`].
///
/// The key `type` maps to the `ticket_type` field. Everything after the
/// **first** `=` is treated as the value, so `key=value=more` is valid and
/// produces the value `"value=more"`.
///
/// # Errors
///
/// Returns an error for:
/// - A string that contains no `=` character.
/// - A key that is not one of `status`, `type`, `priority`, or `title`.
///
/// # Examples
///
/// ```rust,ignore
/// let args = vec!["status=todo".to_string(), "status=in_progress".to_string()];
/// let filter = parse_filters(&args).unwrap();
/// assert_eq!(filter.status.len(), 2);
/// ```
pub fn parse_filters(args: &[String]) -> Result<TicketFilter> {
let mut filter = TicketFilter::default();
for arg in args {
let (key, value) = arg
.split_once('=')
.ok_or_else(|| format!("invalid filter '{arg}': expected 'key=value' format"))?;
match key {
"status" => filter.status.push(value.to_string()),
"type" => filter.ticket_type.push(value.to_string()),
"priority" => filter.priority.push(value.to_string()),
"title" => filter.title.push(value.to_string()),
other => {
return Err(format!(
"unknown filter key '{other}'; expected one of: status, type, priority, title"
)
.into())
}
}
}
Ok(filter)
}

@ -1,287 +0,0 @@
//! Ticket dependency graph computation.
//!
//! Builds a directed graph from a flat list of tickets, tracking both forward
//! (dependency) and reverse (dependent) edges. Used by [`crate::display`] to
//! render an ASCII tree and by the `graph` CLI command for JSON output.
//!
//! ## Edge semantics
//!
//! If ticket B lists ticket A in its `dependencies`, then:
//! - A is a **dependency** of B (A must be done before B)
//! - B is a **dependent** of A (B is waiting on A)
//!
//! The ASCII tree is rendered with **roots** (tickets that no other ticket
//! depends on — the top-level goals) at the top and their **dependencies**
//! indented below — showing "what does this ticket need to be done first?".
//!
//! JSON edges use `{"from": <dependent>, "to": <dependency>}` — i.e. the
//! ticket that is waiting is `from` and the prerequisite ticket is `to`.
use std::collections::{HashMap, HashSet};
use crate::ticket::{Status, Ticket};
// ── Internal helper ───────────────────────────────────────────────────────────
/// Return the canonical display string for a [`Status`] variant.
fn status_str(status: &Status) -> &'static str {
match status {
Status::Todo => "todo",
Status::InProgress => "in_progress",
Status::Done => "done",
Status::Closed => "closed",
Status::Archived => "archived",
Status::Backlog => "backlog",
}
}
// ── Public types ──────────────────────────────────────────────────────────────
/// A single node in the dependency graph.
///
/// Holds a reference to the source [`Ticket`] and the IDs of both its
/// in-graph dependencies (forward edges) and its dependents (reverse edges).
pub struct GraphNode<'a> {
/// The ticket this node represents.
pub ticket: &'a Ticket,
/// IDs of in-graph tickets that this ticket depends on (forward edges).
///
/// Only tickets present in the graph are listed; dangling references in
/// [`Ticket::dependencies`] are silently ignored.
///
/// These are the visual "children" in the ASCII dependency tree — the
/// prerequisites that must be completed before this ticket.
pub dependencies: Vec<&'a str>,
/// IDs of tickets that list this ticket as a dependency (reverse edges).
pub dependents: Vec<&'a str>,
}
/// A directed dependency graph built from a flat list of tickets.
///
/// Build with [`TicketGraph::build`]. Roots (top-level goals) are returned by
/// [`TicketGraph::roots`]. Use [`TicketGraph::subtree`] to extract the IDs
/// reachable from a specific ticket via its dependencies, and
/// [`TicketGraph::to_json_value`] for machine-readable output.
pub struct TicketGraph<'a> {
/// Nodes keyed by ticket ID.
nodes: HashMap<&'a str, GraphNode<'a>>,
/// Ticket IDs in the order they were inserted, for stable iteration.
ids: Vec<&'a str>,
}
impl<'a> TicketGraph<'a> {
/// Build a graph from a slice of tickets.
///
/// Each ticket in `tickets` becomes a node. Forward edges (`dependencies`)
/// and reverse edges (`dependents`) are both populated. References to IDs
/// not present in `tickets` are silently ignored.
///
/// # Example
///
/// ```rust,ignore
/// let graph = TicketGraph::build(&tickets);
/// for root in graph.roots() {
/// println!("{} {}", root.id, root.title);
/// }
/// ```
pub fn build(tickets: &'a [Ticket]) -> Self {
let mut nodes: HashMap<&'a str, GraphNode<'a>> = HashMap::with_capacity(tickets.len());
let mut ids: Vec<&'a str> = Vec::with_capacity(tickets.len());
// First pass: create a node for every ticket.
for ticket in tickets {
let id: &'a str = ticket.id.as_str();
nodes.insert(
id,
GraphNode {
ticket,
dependencies: Vec::new(),
dependents: Vec::new(),
},
);
ids.push(id);
}
// Second pass: collect edges (avoids simultaneous mutable borrows).
// Each edge is (dependent_id, dependency_id).
let mut edges: Vec<(&'a str, &'a str)> = Vec::new();
for ticket in tickets {
let ticket_id: &'a str = ticket.id.as_str();
for dep_id_owned in &ticket.dependencies {
let dep_id: &str = dep_id_owned.as_str();
// Only add an edge if the dependency exists in this graph.
if let Some((&stored_dep_id, _)) = nodes.get_key_value(dep_id) {
edges.push((ticket_id, stored_dep_id));
}
}
}
// Apply collected edges to both sides of each node.
for (dependent_id, dependency_id) in edges {
if let Some(node) = nodes.get_mut(dependent_id) {
node.dependencies.push(dependency_id);
}
if let Some(node) = nodes.get_mut(dependency_id) {
node.dependents.push(dependent_id);
}
}
TicketGraph { nodes, ids }
}
/// Return tickets that no other ticket depends on, sorted by priority descending.
///
/// These are the top-level goals for the ASCII dependency tree renderer.
/// A ticket is a root when its `dependents` list is empty — nothing in the
/// graph is waiting on it, so it represents an end goal rather than a
/// prerequisite.
pub fn roots(&self) -> Vec<&'a Ticket> {
let mut roots: Vec<&'a Ticket> = self
.ids
.iter()
.filter_map(|id| self.nodes.get(id))
.filter(|node| node.dependents.is_empty())
.map(|node| node.ticket)
.collect();
roots.sort_by(|a, b| b.priority.cmp(&a.priority));
roots
}
/// Return the node for a ticket ID, or `None` if not present in the graph.
pub fn get_node(&self, id: &str) -> Option<&GraphNode<'a>> {
self.nodes.get(id)
}
/// Return all ticket IDs reachable from `root_id` via dependency edges,
/// in depth-first order, including `root_id` itself.
///
/// "Reachable via dependencies" means: `root_id`, plus every ticket that
/// `root_id` depends on, plus every ticket those depend on, and so on.
/// This answers "what tickets does `root_id` need (directly or
/// transitively)?".
///
/// Cycles are handled by a visited set — each ID appears at most once.
/// Returns an empty `Vec` when `root_id` is not in the graph.
pub fn subtree(&self, root_id: &str) -> Vec<&'a str> {
let mut result: Vec<&'a str> = Vec::new();
let mut visited: HashSet<&'a str> = HashSet::new();
if let Some((&stored_id, _)) = self.nodes.get_key_value(root_id) {
dfs_subtree(&self.nodes, stored_id, &mut visited, &mut result);
}
result
}
/// Serialise the full graph as a JSON object with `nodes` and `edges`.
///
/// Each node includes `id`, `title`, `status`, `priority`, and
/// `dependencies` (only in-graph dependency IDs).
///
/// Each edge is `{"from": <dependent_id>, "to": <dependency_id>}` — meaning
/// the ticket identified by `from` depends on (must wait for) `to`.
pub fn to_json_value(&self) -> serde_json::Value {
let nodes: Vec<serde_json::Value> = self
.ids
.iter()
.filter_map(|id| self.nodes.get(id))
.map(|node| {
serde_json::json!({
"id": node.ticket.id,
"title": node.ticket.title,
"status": status_str(&node.ticket.status),
"priority": node.ticket.priority,
"dependencies": node.dependencies,
})
})
.collect();
// Edges point from the dependent (waiting) ticket to the dependency (prerequisite).
let edges: Vec<serde_json::Value> = self
.ids
.iter()
.filter_map(|id| self.nodes.get(id))
.flat_map(|node| {
let from = node.ticket.id.as_str();
node.dependencies
.iter()
.map(move |&to| serde_json::json!({ "from": from, "to": to }))
})
.collect();
serde_json::json!({ "nodes": nodes, "edges": edges })
}
/// Serialise the subtree rooted at `root_id` as a JSON object.
///
/// Same structure as [`to_json_value`] but limited to nodes and edges
/// within the subtree reachable from `root_id` via dependency edges.
/// Returns an empty `{"nodes":[],"edges":[]}` object when `root_id` is
/// not in the graph.
pub fn to_subtree_json_value(&self, root_id: &str) -> serde_json::Value {
let reachable: HashSet<&'a str> = self.subtree(root_id).into_iter().collect();
let nodes: Vec<serde_json::Value> = self
.ids
.iter()
.filter(|&&id| reachable.contains(id))
.filter_map(|id| self.nodes.get(id))
.map(|node| {
let deps: Vec<&str> = node
.dependencies
.iter()
.copied()
.filter(|&d| reachable.contains(d))
.collect();
serde_json::json!({
"id": node.ticket.id,
"title": node.ticket.title,
"status": status_str(&node.ticket.status),
"priority": node.ticket.priority,
"dependencies": deps,
})
})
.collect();
let edges: Vec<serde_json::Value> = self
.ids
.iter()
.filter(|&&id| reachable.contains(id))
.filter_map(|id| self.nodes.get(id))
.flat_map(|node| {
let from = node.ticket.id.as_str();
node.dependencies
.iter()
.copied()
.filter(|&to| reachable.contains(to))
.map(move |to| serde_json::json!({ "from": from, "to": to }))
})
.collect();
serde_json::json!({ "nodes": nodes, "edges": edges })
}
}
// ── Private helpers ───────────────────────────────────────────────────────────
/// Depth-first traversal following `dependencies` edges.
///
/// Visits `id` and recursively visits each of its dependencies (tickets that
/// `id` depends on). A `visited` set prevents revisiting nodes, making the
/// function safe even when the data contains dependency cycles.
fn dfs_subtree<'a>(
nodes: &HashMap<&'a str, GraphNode<'a>>,
id: &'a str,
visited: &mut HashSet<&'a str>,
result: &mut Vec<&'a str>,
) {
if !visited.insert(id) {
return;
}
result.push(id);
if let Some(node) = nodes.get(id) {
// Clone the list to avoid holding an immutable borrow while we recurse.
let dependencies: Vec<&'a str> = node.dependencies.clone();
for dep_id in dependencies {
dfs_subtree(nodes, dep_id, visited, result);
}
}
}

@ -1,938 +0,0 @@
//! `nbd` — CLI entry point.
//!
//! Parses subcommands with `clap` and dispatches to the appropriate command
//! handler. All file I/O is delegated to [`store`] and output is rendered by
//! [`display`].
mod display;
mod filter;
mod graph;
mod store;
mod ticket;
/// The CLAUDE.md snippet embedded at compile time from `src/claude_md_snippet.md`.
///
/// Projects adopting `nbd` can append this to their own CLAUDE.md:
///
/// ```sh
/// nbd claude-md >> CLAUDE.md
/// ```
const CLAUDE_MD_SNIPPET: &str = include_str!("claude_md_snippet.md");
/// Full version string embedded at compile time: `"X.Y.Z+shortsha"`.
///
/// The semver comes from `Cargo.toml` via `CARGO_PKG_VERSION`; the short SHA
/// is injected by `build.rs` via `GIT_SHORT_SHA`.
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "+", env!("GIT_SHORT_SHA"));
#[cfg(test)]
mod tests;
use clap::{Parser, Subcommand};
use crate::graph::TicketGraph;
use crate::store::{
detect_format, ensure_tickets_dir, find_nbd_root, find_ticket_path, list_tickets,
list_tickets_cached, migrate_tickets, read_ticket, resolve_id, write_ticket, FileFormat,
};
use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType};
// ── CLI definition ────────────────────────────────────────────────────────────
/// CLI for managing work tickets targeted at agent workflows.
///
/// Tickets are stored as JSON files in `.nbd/tickets/` inside the nearest
/// ancestor directory that contains a `.nbd/` folder, discovered by traversing
/// upward from the current working directory (like `git` finds `.git/`).
#[derive(Parser)]
#[command(name = "nbd", about = "Manage work tickets for agent workflows", version = VERSION)]
struct Cli {
/// Output machine-readable JSON instead of a human-readable table.
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Commands,
}
/// Available `nbd` subcommands.
#[derive(Subcommand)]
enum Commands {
/// Create a new ticket and print it.
Create {
/// Short summary of the work to be done.
#[arg(long)]
title: String,
/// Long-form description (optional, defaults to empty).
#[arg(long, default_value = "")]
body: String,
/// Priority on a scale of 010 (default: 5).
#[arg(long, default_value_t = 5)]
priority: u8,
/// Lifecycle status: `todo`, `in_progress`, or `done` (default: `todo`).
#[arg(long, default_value = "todo")]
status: String,
/// Ticket category: `project`, `feature`, `task`, or `bug` (default: `task`).
#[arg(long = "type", default_value = "task")]
ticket_type: String,
/// Comma-separated list of dependency ticket IDs (e.g. `a3f9c2,b7d41e`).
#[arg(long)]
deps: Option<String>,
/// File format to use for storage: `json`, `md`, `toml`, or `jsonb` (default: `json`).
#[arg(long = "ftype", default_value = "json")]
ftype: String,
},
/// Print a single ticket by ID.
Read {
/// The 6-character hex ticket ID to look up.
id: String,
},
/// List tickets sorted by priority (highest first).
///
/// By default, tickets with status `done`, `closed`, `archived`, or
/// `backlog` are excluded. Pass `--all` to include all tickets, or
/// `--filter status=*` to override only the status exclusion.
List {
/// Filter tickets by field: repeatable `key=value` pairs.
///
/// Keys: `status`, `type`, `priority`, `title`.
/// Different keys are ANDed; the same key with multiple values is ORed.
/// Values support glob wildcards: `title=*login*`.
///
/// If no `status` filter is provided, tickets with status `done`,
/// `closed`, `archived`, or `backlog` are excluded automatically.
/// Provide `--filter status=*` to override, or use `--all`.
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
/// Show all tickets regardless of status, including `done`, `closed`,
/// `archived`, and `backlog`.
///
/// Equivalent to `--filter status=*` but takes precedence over any
/// `--filter` arguments when both are supplied.
#[arg(long)]
all: bool,
},
/// Initialise a new `.nbd/tickets/` store in the current directory.
///
/// Analogous to `git init` — safe to run multiple times (idempotent).
Init,
/// List tickets that are ready to work on right now.
///
/// A ticket is ready when its status is not `done` and every ticket it
/// depends on has status `done`. Tickets with no dependencies and status
/// `todo` or `in_progress` are always ready.
///
/// With an optional `<id>`, restricts results to the dependency subtree of
/// that ticket — only ready tickets that `<id>` depends on (directly or
/// transitively) are returned. The scoping ticket itself is excluded.
Ready {
/// Optional ticket ID or unique prefix to scope results to its dependency subtree.
///
/// When provided, only ready tickets within the subtree that `<id>`
/// depends on (directly or transitively) are returned. The ticket
/// identified by `<id>` itself is never included in the results.
id: Option<String>,
/// Filter ready tickets by field: repeatable `key=value` pairs.
///
/// Applied after the ready check — narrows within already-ready tickets.
/// Keys: `status`, `type`, `priority`, `title`. Values support globs.
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
},
/// Choose the highest-priority ticket that is ready to work on.
///
/// A ticket is ready when its status is not `done` and every ticket it
/// depends on has status `done`. Returns the single highest-priority
/// ready ticket, optionally narrowed by `--filter KEY=VALUE`.
///
/// With an optional `<id>`, restricts results to the dependency subtree of
/// that ticket — only the highest-priority ready ticket that `<id>` depends
/// on (directly or transitively) is returned. The scoping ticket itself is
/// excluded.
///
/// Exits 0 even when no ready ticket exists.
Next {
/// Optional ticket ID or unique prefix to scope results to its dependency subtree.
///
/// When provided, only the highest-priority ready ticket within the
/// subtree that `<id>` depends on (directly or transitively) is
/// returned. The ticket identified by `<id>` itself is never returned.
id: Option<String>,
/// Filter ready tickets: key=value pairs (repeatable).
/// AND between different keys, OR within same key.
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
},
/// Re-serialise all ticket files through the current schema.
///
/// Brings existing files into conformance with the current data model:
/// removes stale fields, adds new fields with their defaults, and
/// normalises formatting. Exits zero even when some files have errors.
Migrate {
/// Print what would change without writing any files.
#[arg(long)]
dry_run: bool,
/// Only migrate tickets matching these filters: repeatable `key=value` pairs.
///
/// Non-matching tickets are counted as skipped in the report.
/// Keys: `status`, `type`, `priority`, `title`. Values support globs.
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
},
/// Archive a ticket by setting its status to `archived`.
///
/// The ticket is preserved on disk but excluded from normal `nbd list`
/// output. Archived tickets count as resolved for dependency purposes.
/// Use `nbd list --all` or `--filter status=archived` to view them.
///
/// This is syntactic sugar for `nbd update <id> --status archived`.
Archive {
/// The 6-character hex ticket ID to archive.
id: String,
},
/// Print a CLAUDE.md snippet for adopting nbd in a project.
///
/// The snippet is baked into the binary at compile time and covers
/// the nbd workflow, core commands, and usage guidelines.
///
/// Append the snippet to your project's CLAUDE.md:
///
/// ```sh
/// nbd claude-md >> CLAUDE.md
/// ```
///
/// With `--json`, outputs `{"snippet": "..."}` for programmatic use.
/// Does not require a `.nbd/` store to be present.
ClaudeMd,
/// Draw an ASCII dependency graph of all tickets, or a subtree rooted at a
/// specific ticket.
///
/// Without an ID, renders every ticket as a dependency forest: roots (tickets
/// with no dependencies) appear at the top; tickets that depend on them are
/// indented below using box-drawing characters (├──, └──, │).
///
/// With an ID, renders only the subtree reachable from that ticket via its
/// dependents (tickets that list it as a dependency, transitively).
///
/// Cycles are detected and labelled `[cycle]` rather than looping forever.
Graph {
/// Optional ticket ID or unique prefix to root the graph at.
///
/// When omitted, the full dependency forest is rendered.
id: Option<String>,
/// Filter tickets before building the graph: repeatable `key=value` pairs.
///
/// Keys: `status`, `type`, `priority`, `title`. Values support globs.
/// Applied to the full ticket list before graph construction; tickets that
/// do not match are excluded from all nodes and edges.
#[arg(long = "filter", value_name = "KEY=VALUE")]
filter: Vec<String>,
},
/// Update fields of an existing ticket and print the result.
///
/// Only the flags you supply are changed; all other fields retain their
/// current values.
Update {
/// The 6-character hex ticket ID to modify.
id: String,
/// New title.
#[arg(long)]
title: Option<String>,
/// New body.
#[arg(long)]
body: Option<String>,
/// New priority (010).
#[arg(long)]
priority: Option<u8>,
/// New status: `todo`, `in_progress`, or `done`.
#[arg(long)]
status: Option<String>,
/// New ticket type: `project`, `feature`, `task`, or `bug`.
#[arg(long = "type")]
ticket_type: Option<String>,
/// New comma-separated dependency IDs (replaces the existing list).
#[arg(long)]
deps: Option<String>,
/// Convert to a different file format: `json`, `md`, `toml`, or `jsonb`.
///
/// When specified, the ticket is re-serialised in the new format and
/// the old file is removed if the extension differs.
#[arg(long = "ftype")]
ftype: Option<String>,
},
}
// ── Entry point ───────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if let Err(e) = dispatch(cli).await {
eprintln!("error: {e}");
std::process::exit(1);
}
}
/// Route the parsed CLI arguments to the appropriate command handler.
async fn dispatch(cli: Cli) -> store::Result<()> {
match cli.command {
Commands::Create {
title,
body,
priority,
status,
ticket_type,
deps,
ftype,
} => {
cmd_create(
title,
body,
priority,
status,
ticket_type,
deps,
ftype,
cli.json,
)
.await
}
Commands::Init => cmd_init(cli.json).await,
Commands::Next { id, filter } => cmd_next(id, filter, cli.json).await,
Commands::Ready { id, filter } => cmd_ready(id, filter, cli.json).await,
Commands::Migrate { dry_run, filter } => cmd_migrate(filter, dry_run, cli.json).await,
Commands::Archive { id } => cmd_archive(id, cli.json).await,
Commands::ClaudeMd => cmd_claude_md(cli.json),
Commands::Graph { id, filter } => cmd_graph(id, filter, cli.json).await,
Commands::Read { id } => cmd_read(id, cli.json).await,
Commands::List { filter, all } => cmd_list(filter, all, cli.json).await,
Commands::Update {
id,
title,
body,
priority,
status,
ticket_type,
deps,
ftype,
} => {
cmd_update(
id,
title,
body,
priority,
status,
ticket_type,
deps,
ftype,
cli.json,
)
.await
}
}
}
// ── Parsing helpers ───────────────────────────────────────────────────────────
/// Parse a [`Status`] from its lowercase string representation.
///
/// Accepts `"todo"`, `"in_progress"`, `"done"`, `"closed"`, `"archived"`,
/// and `"backlog"`.
///
/// # Errors
///
/// Returns an error if `s` does not match a known variant.
fn parse_status(s: &str) -> store::Result<Status> {
match s {
"todo" => Ok(Status::Todo),
"in_progress" => Ok(Status::InProgress),
"done" => Ok(Status::Done),
"closed" => Ok(Status::Closed),
"archived" => Ok(Status::Archived),
"backlog" => Ok(Status::Backlog),
other => Err(format!(
"unknown status '{other}'; expected 'todo', 'in_progress', 'done', 'closed', 'archived', or 'backlog'"
)
.into()),
}
}
/// Parse a [`TicketType`] from its lowercase string representation.
///
/// Accepts `"project"`, `"feature"`, `"task"`, and `"bug"`.
///
/// # Errors
///
/// Returns an error if `s` does not match a known variant.
fn parse_ticket_type(s: &str) -> store::Result<TicketType> {
match s {
"project" => Ok(TicketType::Project),
"feature" => Ok(TicketType::Feature),
"task" => Ok(TicketType::Task),
"bug" => Ok(TicketType::Bug),
other => Err(format!(
"unknown ticket type '{other}'; expected 'project', 'feature', 'task', or 'bug'"
)
.into()),
}
}
/// Parse a [`FileFormat`] from its string representation.
///
/// Accepts `"json"`, `"md"`, `"toml"`, and `"jsonb"`.
///
/// # Errors
///
/// Returns an error if `s` does not match a known format.
fn parse_file_format(s: &str) -> store::Result<FileFormat> {
FileFormat::from_str(s).ok_or_else(|| {
format!("unknown file format '{s}'; expected 'json', 'md', 'toml', or 'jsonb'").into()
})
}
/// Split a comma-separated dependency string into a `Vec<String>`.
///
/// Returns an empty `Vec` when `deps` is `None` or an empty string.
fn parse_deps(deps: Option<&str>) -> Vec<String> {
match deps {
None | Some("") => Vec::new(),
Some(s) => s.split(',').map(|id| id.trim().to_string()).collect(),
}
}
/// Verify that every ID in `deps` refers to an existing ticket.
///
/// Each entry may be a full ID or a unique prefix; `resolve_id` is used to
/// expand prefixes before checking existence. The `deps` slice is mutated
/// in-place so that all entries are replaced with their resolved full IDs.
///
/// # Errors
///
/// Returns an error that names the first missing or ambiguous dependency.
async fn validate_deps(root: &std::path::Path, deps: &mut [String]) -> store::Result<()> {
for dep_id in deps.iter_mut() {
let resolved = resolve_id(root, dep_id).await.map_err(
|_| -> Box<dyn std::error::Error + Send + Sync> {
format!("dependency '{dep_id}' not found").into()
},
)?;
*dep_id = resolved;
}
Ok(())
}
// ── Command handlers ──────────────────────────────────────────────────────────
/// Initialise a `.nbd/tickets/` store in the current working directory.
///
/// Uses `create_dir_all`, so it is safe to call repeatedly (idempotent).
/// Does **not** call [`find_nbd_root`] — the store is always created in cwd.
async fn cmd_init(json: bool) -> store::Result<()> {
let cwd = std::env::current_dir()?;
ensure_tickets_dir(&cwd).await?;
if json {
let path = cwd.join(".nbd").join("tickets");
println!(
"{{\"root\":{}}}",
serde_json::to_string(&path.to_string_lossy())?
);
} else {
println!(
"initialised .nbd/tickets/ in {}",
cwd.join(".nbd").join("tickets").display()
);
}
Ok(())
}
/// List all tickets that are ready to work on and print them.
///
/// A ticket is *ready* when its status is not [`Status::Done`] and every ID in
/// its `dependencies` list belongs to a ticket with `status == Done`.
/// Missing dependency IDs are treated conservatively — the ticket is **not**
/// ready if any dep cannot be resolved.
///
/// When `scope_id` is `Some`, results are restricted to the dependency subtree
/// of the identified ticket — only ready tickets that the scoping ticket depends
/// on (directly or transitively) are returned. The scoping ticket itself is
/// excluded from the results.
///
/// `filter_args` are applied after the ready check, narrowing the results.
async fn cmd_ready(
scope_id: Option<String>,
filter_args: Vec<String>,
json: bool,
) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let all = list_tickets_cached(&root).await?;
// Build the set of IDs that are resolved (done, closed, or archived).
// Both closed and archived tickets count as resolved for dependency purposes.
let done_ids: std::collections::HashSet<&str> = all
.iter()
.filter(|t| {
t.status == crate::ticket::Status::Done
|| t.status == crate::ticket::Status::Closed
|| t.status == crate::ticket::Status::Archived
})
.map(|t| t.id.as_str())
.collect();
// If a scope ID was provided, resolve it and build the dependency subtree.
// The candidate pool is restricted to tickets within that subtree (excluding
// the scoping ticket itself).
let scope_subtree: Option<std::collections::HashSet<String>> = match scope_id {
Some(raw) => {
let resolved = resolve_id(&root, &raw).await?;
let graph = TicketGraph::build(&all);
// subtree() includes the root itself; exclude it from candidates.
let ids: std::collections::HashSet<String> = graph
.subtree(&resolved)
.into_iter()
.filter(|&id| id != resolved.as_str())
.map(|id| id.to_string())
.collect();
Some(ids)
}
None => None,
};
let ready: Vec<&crate::ticket::Ticket> = all
.iter()
.filter(|t| {
// If a subtree scope was set, only include tickets in that scope.
if let Some(ref subtree) = scope_subtree {
if !subtree.contains(&t.id) {
return false;
}
}
t.status != crate::ticket::Status::Done
&& t.status != crate::ticket::Status::Closed
&& t.status != crate::ticket::Status::Archived
&& t.status != crate::ticket::Status::Backlog
&& t.dependencies
.iter()
.all(|dep| done_ids.contains(dep.as_str()))
&& filter.matches(t)
})
.collect();
if json {
display::print_list_json(&ready.into_iter().cloned().collect::<Vec<_>>());
} else {
display::print_list(&ready.into_iter().cloned().collect::<Vec<_>>());
}
Ok(())
}
/// Select the single highest-priority ticket that is ready to work on.
///
/// Uses the same readiness definition as [`cmd_ready`]: status not `done` and
/// every dependency has status `done`. Missing dependency IDs make a ticket
/// **not** ready.
///
/// When `scope_id` is `Some`, results are restricted to the dependency subtree
/// of the identified ticket — only the highest-priority ready ticket that the
/// scoping ticket depends on (directly or transitively) is returned. The scoping
/// ticket itself is excluded from the results.
///
/// With `--json`, outputs `{"next": {...ticket...}}` when a ticket is found or
/// `{"next": null}` when none are ready, so callers always receive an object
/// with a `"next"` key.
async fn cmd_next(
scope_id: Option<String>,
filter_args: Vec<String>,
json: bool,
) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let all = list_tickets_cached(&root).await?; // sorted by priority desc
let done_ids: std::collections::HashSet<&str> = all
.iter()
.filter(|t| {
t.status == crate::ticket::Status::Done
|| t.status == crate::ticket::Status::Closed
|| t.status == crate::ticket::Status::Archived
})
.map(|t| t.id.as_str())
.collect();
// If a scope ID was provided, resolve it and build the dependency subtree.
// The candidate pool is restricted to tickets within that subtree (excluding
// the scoping ticket itself).
let scope_subtree: Option<std::collections::HashSet<String>> = match scope_id {
Some(raw) => {
let resolved = resolve_id(&root, &raw).await?;
let graph = TicketGraph::build(&all);
// subtree() includes the root itself; exclude it from candidates.
let ids: std::collections::HashSet<String> = graph
.subtree(&resolved)
.into_iter()
.filter(|&id| id != resolved.as_str())
.map(|id| id.to_string())
.collect();
Some(ids)
}
None => None,
};
let next = all.iter().find(|t| {
// If a subtree scope was set, only include tickets in that scope.
if let Some(ref subtree) = scope_subtree {
if !subtree.contains(&t.id) {
return false;
}
}
t.status != crate::ticket::Status::Done
&& t.status != crate::ticket::Status::Closed
&& t.status != crate::ticket::Status::Archived
&& t.status != crate::ticket::Status::Backlog
&& t.dependencies
.iter()
.all(|dep| done_ids.contains(dep.as_str()))
&& filter.matches(t)
});
if json {
match next {
Some(ticket) => {
let value = serde_json::json!({
"next": display::ticket_to_json_value(ticket)
});
println!("{}", serde_json::to_string_pretty(&value)?);
}
None => println!("{}", serde_json::json!({"next": null})),
}
} else {
match next {
Some(ticket) => display::print_ticket(ticket),
None => println!("No ready tickets."),
}
}
Ok(())
}
/// Re-serialise all ticket files through the current schema and print a summary.
///
/// When `dry_run` is `true`, describe what *would* change without writing any
/// files. The command exits zero even when individual files fail to parse —
/// those errors are included in the summary.
///
/// `filter_args` restrict which tickets are candidates; non-matching tickets
/// are counted as skipped in the report.
async fn cmd_migrate(filter_args: Vec<String>, dry_run: bool, json: bool) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let report = migrate_tickets(&root, dry_run, &filter).await?;
if json {
display::print_migrate_report_json(&report);
} else {
display::print_migrate_report(&report);
}
Ok(())
}
/// Create a new ticket, persist it, and print it.
///
/// Generates a fresh ID, validates `priority` and all dependency IDs, then
/// writes the ticket to `.nbd/tickets/{id}.{ext}` using the chosen format.
#[allow(clippy::too_many_arguments)]
async fn cmd_create(
title: String,
body: String,
priority: u8,
status: String,
ticket_type: String,
deps: Option<String>,
ftype: String,
json: bool,
) -> store::Result<()> {
validate_priority(priority)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let format = parse_file_format(&ftype)?;
let root = find_nbd_root()?;
ensure_tickets_dir(&root).await?;
let mut dependencies = parse_deps(deps.as_deref());
validate_deps(&root, &mut dependencies).await?;
let id = generate_id();
let mut ticket = Ticket::new(id, title);
ticket.body = body;
ticket.priority = priority;
ticket.status = parse_status(&status)?;
ticket.ticket_type = parse_ticket_type(&ticket_type)?;
ticket.dependencies = dependencies;
write_ticket(&root, &ticket, format).await?;
if json {
display::print_ticket_json(&ticket);
} else {
display::print_ticket(&ticket);
}
Ok(())
}
/// Read a ticket by ID (or unique prefix) and print it.
async fn cmd_read(id: String, json: bool) -> store::Result<()> {
let root = find_nbd_root()?;
let id = resolve_id(&root, &id).await?;
let ticket = read_ticket(&root, &id).await?;
if json {
display::print_ticket_json(&ticket);
} else {
display::print_ticket(&ticket);
}
Ok(())
}
/// List tickets sorted by priority and print them.
///
/// `filter_args` are optional `key=value` expressions that narrow the output.
/// `all` bypasses the default done/closed/archived/backlog exclusion.
///
/// **Default behaviour:** tickets with status [`Status::Done`],
/// [`Status::Closed`], [`Status::Archived`], or [`Status::Backlog`] are
/// excluded unless the caller provides at least one `status=…` filter argument
/// or passes `--all`. Pass `--filter status=*` or `--all` to see every ticket.
async fn cmd_list(filter_args: Vec<String>, all: bool, json: bool) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let tickets: Vec<Ticket> = list_tickets_cached(&root)
.await?
.into_iter()
.filter(|t| {
if all {
// --all: skip status exclusions; apply every other filter.
filter.matches(t)
} else {
// If no status filter was provided, exclude done, closed, archived, and backlog tickets by default.
let status_ok = if filter.has_status_filter() {
filter.matches_status(t)
} else {
t.status != Status::Done
&& t.status != Status::Closed
&& t.status != Status::Archived
&& t.status != Status::Backlog
};
status_ok && filter.matches_except_status(t)
}
})
.collect();
if json {
display::print_list_json(&tickets);
} else {
display::print_list(&tickets);
}
Ok(())
}
/// Archive a ticket by setting its status to [`Status::Archived`] and printing it.
///
/// The ticket is preserved on disk but excluded from normal `nbd list` output.
/// Archived tickets count as resolved for dependency purposes, unblocking any
/// dependents. The file is re-written in its existing format. This is syntactic
/// sugar for `nbd update <id> --status archived`.
async fn cmd_archive(id: String, json: bool) -> store::Result<()> {
let root = find_nbd_root()?;
let id = resolve_id(&root, &id).await?;
let existing_path = find_ticket_path(&root, &id).await?;
let format = detect_format(&existing_path);
let mut ticket = read_ticket(&root, &id).await?;
ticket.status = Status::Archived;
write_ticket(&root, &ticket, format).await?;
if json {
display::print_ticket_json(&ticket);
} else {
display::print_ticket(&ticket);
}
Ok(())
}
/// Print the embedded CLAUDE.md snippet to stdout.
///
/// With `json = true`, wraps the snippet in `{"snippet": "..."}` for
/// programmatic consumption. Without `--json`, emits the raw markdown so the
/// caller can redirect it directly into a CLAUDE.md file.
///
/// This command never calls [`find_nbd_root`] — it works even when no `.nbd/`
/// store exists.
fn cmd_claude_md(json: bool) -> store::Result<()> {
if json {
let value = serde_json::json!({ "snippet": CLAUDE_MD_SNIPPET });
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
print!("{CLAUDE_MD_SNIPPET}");
}
Ok(())
}
/// Render the ticket dependency graph and print it.
///
/// With no `id`, renders the full dependency forest (all tickets that pass
/// `filter_args`). With an `id` (or unique prefix), renders only the subtree
/// reachable from that ticket via its dependents.
///
/// `filter_args` are applied to the full ticket list before building the graph;
/// tickets that do not match are excluded from all nodes and edges.
async fn cmd_graph(id: Option<String>, filter_args: Vec<String>, json: bool) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let tickets: Vec<Ticket> = list_tickets(&root)
.await?
.into_iter()
.filter(|t| filter.matches(t))
.collect();
let graph = TicketGraph::build(&tickets);
match id {
Some(raw_id) => {
let resolved = resolve_id(&root, &raw_id).await?;
if json {
let value = graph.to_subtree_json_value(&resolved);
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
display::print_subtree(&graph, &resolved);
}
}
None => {
if json {
let value = graph.to_json_value();
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
display::print_graph(&graph);
}
}
}
Ok(())
}
/// Update the specified fields of an existing ticket, persist it, and print it.
///
/// Only the flags explicitly passed on the command line are applied; all other
/// fields keep their current values. `id` may be a full 6-character ID or a
/// unique prefix.
///
/// When `ftype` is supplied and differs from the existing format, the ticket
/// is written in the new format and the old file is removed.
#[allow(clippy::too_many_arguments)]
async fn cmd_update(
id: String,
title: Option<String>,
body: Option<String>,
priority: Option<u8>,
status: Option<String>,
ticket_type: Option<String>,
deps: Option<String>,
ftype: Option<String>,
json: bool,
) -> store::Result<()> {
let root = find_nbd_root()?;
let id = resolve_id(&root, &id).await?;
// Detect the existing file's format so we can preserve or replace it.
let existing_path = find_ticket_path(&root, &id).await?;
let old_format = detect_format(&existing_path);
let mut ticket = read_ticket(&root, &id).await?;
let old = ticket.clone();
if let Some(t) = title {
ticket.title = t;
}
if let Some(b) = body {
ticket.body = b;
}
if let Some(p) = priority {
validate_priority(p)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
ticket.priority = p;
}
if let Some(s) = status {
ticket.status = parse_status(&s)?;
}
if let Some(tt) = ticket_type {
ticket.ticket_type = parse_ticket_type(&tt)?;
}
if deps.is_some() {
let mut dependencies = parse_deps(deps.as_deref());
validate_deps(&root, &mut dependencies).await?;
ticket.dependencies = dependencies;
}
let new_format = match ftype {
Some(ref s) => parse_file_format(s)?,
None => old_format,
};
write_ticket(&root, &ticket, new_format).await?;
// Remove the old file when the format changed (different extension = different path).
if new_format != old_format {
tokio::fs::remove_file(&existing_path).await?;
}
if json {
display::print_ticket_json(&ticket);
} else {
display::print_diff(&old, &ticket);
}
Ok(())
}

@ -1,837 +0,0 @@
//! File I/O and directory traversal for ticket storage.
//!
//! Tickets are stored in `.nbd/tickets/` relative to the project root.
//! Each ticket is a single file named `{id}.{ext}`, where the extension
//! depends on the [`FileFormat`]:
//!
//! | Format | Extension | Description |
//! |---|---|---|
//! | [`FileFormat::Json`] | `.json` | Pretty-printed JSON (default) |
//! | [`FileFormat::Markdown`] | `.md` | Markdown body with TOML frontmatter |
//! | [`FileFormat::Toml`] | `.toml` | TOML |
//! | [`FileFormat::Jsonb`] | `.jsonb` | CBOR binary |
//!
//! The root is discovered by walking up from the current working directory
//! until a `.nbd/` directory is found, mirroring how `git` locates `.git/`.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::filter::TicketFilter;
use crate::ticket::{Status, Ticket, TicketType};
/// Convenience alias for fallible operations in this module.
///
/// The error type is a heap-allocated trait object so that both `io::Error`
/// and `serde_json::Error` (and any other `std::error::Error` implementor)
/// can be returned with `?` without additional wrapping.
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
/// Convert a string message into a boxed error, with an unambiguous type.
fn msg_err(s: String) -> Box<dyn std::error::Error + Send + Sync> {
s.into()
}
// ── FileFormat ────────────────────────────────────────────────────────────────
/// The on-disk serialisation format for a ticket file.
///
/// The format is determined by the file extension when reading, and must be
/// supplied explicitly when writing a new file. `Json` is the default.
///
/// # Examples
///
/// ```
/// use nbd::store::FileFormat;
/// assert_eq!(FileFormat::Json.extension(), "json");
/// assert_eq!(FileFormat::Markdown.extension(), "md");
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileFormat {
/// Pretty-printed JSON (`.json`). The original and default format.
#[default]
Json,
/// Markdown file with TOML frontmatter (`.md`).
///
/// The ticket body is stored as the file content after the closing `+++`
/// delimiter; all other fields live in the TOML frontmatter block.
Markdown,
/// TOML (`.toml`). All ticket fields except `id` are stored as TOML.
Toml,
/// CBOR binary encoding (`.jsonb`). Compact binary alternative to JSON.
Jsonb,
}
impl FileFormat {
/// The file extension associated with this format (without a leading dot).
pub fn extension(self) -> &'static str {
match self {
FileFormat::Json => "json",
FileFormat::Markdown => "md",
FileFormat::Toml => "toml",
FileFormat::Jsonb => "jsonb",
}
}
/// Parse a [`FileFormat`] from a user-supplied string.
///
/// Accepts `"json"`, `"md"`, `"toml"`, and `"jsonb"`.
/// Returns `None` for unrecognised values.
pub fn from_str(s: &str) -> Option<Self> {
match s {
"json" => Some(FileFormat::Json),
"md" => Some(FileFormat::Markdown),
"toml" => Some(FileFormat::Toml),
"jsonb" => Some(FileFormat::Jsonb),
_ => None,
}
}
}
impl std::fmt::Display for FileFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.extension())
}
}
/// File extensions recognised as ticket files, tried in this order on reads.
const KNOWN_EXTENSIONS: &[&str] = &["json", "md", "toml", "jsonb"];
/// Detect the [`FileFormat`] from a file path's extension.
///
/// Falls back to [`FileFormat::Json`] for absent or unrecognised extensions.
pub fn detect_format(path: &Path) -> FileFormat {
match path.extension().and_then(|e| e.to_str()) {
Some("json") => FileFormat::Json,
Some("md") => FileFormat::Markdown,
Some("toml") => FileFormat::Toml,
Some("jsonb") => FileFormat::Jsonb,
_ => FileFormat::Json,
}
}
// ── Markdown frontmatter helper ───────────────────────────────────────────────
/// Ticket metadata stored in TOML frontmatter for `.md` files.
///
/// The `body` field is intentionally absent here: in the markdown format the
/// body is the file content that follows the closing `+++` delimiter.
#[derive(Serialize, Deserialize)]
struct MarkdownFrontmatter {
title: String,
priority: u8,
status: Status,
ticket_type: TicketType,
dependencies: Vec<String>,
}
// ── Serialisation helpers ─────────────────────────────────────────────────────
/// Serialise `ticket` to a pretty-printed JSON string.
fn serialize_json(ticket: &Ticket) -> Result<String> {
Ok(serde_json::to_string_pretty(ticket)?)
}
/// Serialise `ticket` to a TOML string.
fn serialize_toml(ticket: &Ticket) -> Result<String> {
Ok(toml::to_string(ticket)?)
}
/// Serialise `ticket` to a markdown document with TOML frontmatter.
///
/// Format:
/// ```text
/// +++
/// title = "..."
/// priority = 5
/// ...
/// +++
/// Body content here.
/// ```
fn serialize_markdown(ticket: &Ticket) -> Result<String> {
let fm = MarkdownFrontmatter {
title: ticket.title.clone(),
priority: ticket.priority,
status: ticket.status.clone(),
ticket_type: ticket.ticket_type.clone(),
dependencies: ticket.dependencies.clone(),
};
let toml_str = toml::to_string(&fm)?;
Ok(format!("+++\n{toml_str}+++\n{}", ticket.body))
}
/// Serialise `ticket` to CBOR binary.
fn serialize_jsonb(ticket: &Ticket) -> Result<Vec<u8>> {
let mut buf = Vec::new();
ciborium::ser::into_writer(ticket, &mut buf)
.map_err(|e| msg_err(format!("CBOR serialization error: {e}")))?;
Ok(buf)
}
// ── Deserialisation helpers ───────────────────────────────────────────────────
/// Deserialise a ticket from JSON bytes.
fn deserialize_json(bytes: &[u8]) -> Result<Ticket> {
Ok(serde_json::from_slice(bytes)?)
}
/// Deserialise a ticket from TOML bytes.
fn deserialize_toml(bytes: &[u8]) -> Result<Ticket> {
let s = std::str::from_utf8(bytes)?;
Ok(toml::from_str(s)?)
}
/// Deserialise a ticket from a markdown document with TOML frontmatter.
///
/// The document must begin with `+++\n`, contain a closing `\n+++\n`, and
/// have TOML key-value pairs between the two delimiters. Everything after the
/// closing delimiter becomes the ticket body.
fn deserialize_markdown(bytes: &[u8]) -> Result<Ticket> {
let content = std::str::from_utf8(bytes)?;
let after_open = content
.strip_prefix("+++\n")
.ok_or("markdown ticket must start with '+++\\n'")?;
let (fm_str, body) = after_open
.split_once("\n+++\n")
.ok_or("markdown ticket is missing a closing '+++' delimiter")?;
let fm: MarkdownFrontmatter = toml::from_str(fm_str)?;
Ok(Ticket {
id: String::new(),
title: fm.title,
body: body.to_string(),
priority: fm.priority,
status: fm.status,
ticket_type: fm.ticket_type,
dependencies: fm.dependencies,
})
}
/// Deserialise a ticket from CBOR binary bytes.
fn deserialize_jsonb(bytes: &[u8]) -> Result<Ticket> {
ciborium::de::from_reader(bytes)
.map_err(|e| msg_err(format!("CBOR deserialization error: {e}")))
}
/// Deserialise `bytes` using the detected `format`.
fn deserialize_by_format(bytes: &[u8], format: FileFormat) -> Result<Ticket> {
match format {
FileFormat::Json => deserialize_json(bytes),
FileFormat::Markdown => deserialize_markdown(bytes),
FileFormat::Toml => deserialize_toml(bytes),
FileFormat::Jsonb => deserialize_jsonb(bytes),
}
}
// ── Directory helpers ─────────────────────────────────────────────────────────
/// Walk upward from `start` until a directory containing `.nbd/` is found.
///
/// Returns the first ancestor path (inclusive of `start`) that contains a
/// `.nbd/` subdirectory, or an error if the filesystem root is reached without
/// finding one.
///
/// This is the low-level, testable variant. Most callers should use
/// [`find_nbd_root`], which starts from the current working directory.
///
/// # Errors
///
/// Returns an error if no `.nbd/` directory exists in `start` or any of its
/// ancestors.
pub fn find_nbd_root_from(start: &Path) -> Result<PathBuf> {
let mut dir = start;
loop {
if dir.join(".nbd").is_dir() {
return Ok(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
Err(
"could not find .nbd/ directory; create `.nbd/tickets/` in your project root to initialise"
.into(),
)
}
/// Walk upward from the current working directory until a `.nbd/` directory is
/// found.
///
/// This is the primary entry point used by all CLI commands. Internally
/// delegates to [`find_nbd_root_from`].
///
/// # Errors
///
/// Returns an error if the current working directory cannot be determined, or
/// if no `.nbd/` directory exists in it or any of its ancestors.
pub fn find_nbd_root() -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
find_nbd_root_from(&cwd)
}
/// Return the path to the tickets directory within a project root.
///
/// This is a pure path computation — it does not check whether the directory
/// exists.
pub fn tickets_dir(root: &Path) -> PathBuf {
root.join(".nbd").join("tickets")
}
/// Create `.nbd/tickets/` under `root` if it does not already exist.
///
/// All intermediate directories (including `.nbd/`) are created as needed.
/// This should only be called by the `create` command; other commands can
/// assume the directory already exists.
///
/// # Errors
///
/// Propagates any I/O error returned by the filesystem.
pub async fn ensure_tickets_dir(root: &Path) -> Result<()> {
let dir = tickets_dir(root);
fs::create_dir_all(dir).await?;
Ok(())
}
// ── Path helpers ──────────────────────────────────────────────────────────────
/// Return the path to a specific ticket file for the given `format`.
///
/// This is a pure path computation — it does not check whether the file
/// exists.
pub fn ticket_path(root: &Path, id: &str, format: FileFormat) -> PathBuf {
tickets_dir(root).join(format!("{id}.{}", format.extension()))
}
/// Find and return the actual on-disk path of a ticket, trying each known
/// extension in order.
///
/// # Errors
///
/// Returns an error if no file with a known extension exists for `id`.
pub async fn find_ticket_path(root: &Path, id: &str) -> Result<PathBuf> {
let dir = tickets_dir(root);
for ext in KNOWN_EXTENSIONS {
let path = dir.join(format!("{id}.{ext}"));
if path.is_file() {
return Ok(path);
}
}
Err(format!("ticket '{id}' not found").into())
}
// ── CRUD operations ───────────────────────────────────────────────────────────
/// Serialise `ticket` and write it to `.nbd/tickets/{id}.{ext}` using
/// `format` to determine both the extension and the serialisation.
///
/// Overwrites any existing file at the same path. The tickets directory must
/// already exist; call [`ensure_tickets_dir`] before calling this for a new
/// ticket.
///
/// # Errors
///
/// Returns an error if serialisation fails or if the file cannot be written.
pub async fn write_ticket(root: &Path, ticket: &Ticket, format: FileFormat) -> Result<()> {
let path = ticket_path(root, &ticket.id, format);
match format {
FileFormat::Json => {
let json = serialize_json(ticket)?;
fs::write(path, json).await?;
}
FileFormat::Markdown => {
let content = serialize_markdown(ticket)?;
fs::write(path, content).await?;
}
FileFormat::Toml => {
let content = serialize_toml(ticket)?;
fs::write(path, content).await?;
}
FileFormat::Jsonb => {
let bytes = serialize_jsonb(ticket)?;
fs::write(path, bytes).await?;
}
}
Ok(())
}
/// Resolve a full 6-character ticket ID from an exact ID or a unique prefix.
///
/// If `id_or_prefix` is already an exact match for a ticket on disk (in any
/// supported format), it is returned unchanged. Otherwise all ticket filenames
/// whose stem starts with `id_or_prefix` are collected and:
///
/// - **0 matches** → error: `"no ticket found matching '{prefix}'"`
/// - **1 match** → the full 6-character ID is returned
/// - **2+ matches** → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."`
///
/// # Errors
///
/// Returns an error if no match is found, the prefix is ambiguous, or the
/// tickets directory cannot be read.
pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result<String> {
let dir = tickets_dir(root);
// Fast path: if exactly 6 chars, check all known extensions.
if id_or_prefix.len() == 6 {
for ext in KNOWN_EXTENSIONS {
if dir.join(format!("{id_or_prefix}.{ext}")).is_file() {
return Ok(id_or_prefix.to_string());
}
}
}
// Scan directory for prefix matches across all known formats.
if !dir.is_dir() {
return Err(format!("no ticket found matching '{id_or_prefix}'").into());
}
let mut entries = fs::read_dir(&dir).await?;
let mut matches: Vec<String> = Vec::new();
while let Some(entry) = entries.next_entry().await? {
// Construct a std::path::PathBuf so it's compatible with Path helpers.
let path: PathBuf = dir.join(entry.file_name());
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if !KNOWN_EXTENSIONS.contains(&ext) {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if stem.starts_with(id_or_prefix) && !matches.contains(&stem.to_string()) {
matches.push(stem.to_string());
}
}
}
match matches.len() {
0 => Err(format!("no ticket found matching '{id_or_prefix}'").into()),
1 => Ok(matches.remove(0)),
_ => {
matches.sort();
Err(format!(
"ambiguous prefix '{id_or_prefix}' matches: {}",
matches.join(", ")
)
.into())
}
}
}
/// Read and deserialise the ticket with the given `id` from disk.
///
/// Extensions are tried in the order defined by [`KNOWN_EXTENSIONS`]. The
/// first matching file is used. The `id` is not stored inside the file; it
/// is injected from the `id` parameter after deserialisation, making the
/// filename stem the authoritative source of truth.
///
/// # Errors
///
/// Returns a descriptive error message if no file is found for `id`.
/// Propagates any other I/O or deserialisation error unchanged.
pub async fn read_ticket(root: &Path, id: &str) -> Result<Ticket> {
let dir = tickets_dir(root);
for ext in KNOWN_EXTENSIONS {
let path = dir.join(format!("{id}.{ext}"));
match fs::read(&path).await {
Ok(bytes) => {
let format = detect_format(&path);
let mut ticket = deserialize_by_format(&bytes, format)?;
ticket.id = id.to_string();
return Ok(ticket);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
}
}
Err(format!("ticket '{id}' not found").into())
}
/// Report produced by [`migrate_tickets`].
///
/// Summarises how many ticket files were updated, were already current,
/// were skipped by the filter, or could not be parsed.
#[derive(Debug)]
pub struct MigrateReport {
/// Number of files that were re-serialised (had stale content).
pub updated: usize,
/// Number of files that were already in the current schema format.
pub already_current: usize,
/// Number of files excluded by the caller-supplied [`TicketFilter`].
pub skipped: usize,
/// Files that could not be deserialised. Each entry is `(filename, error)`.
pub errors: Vec<(String, String)>,
}
/// Re-serialise every ticket file through the current serde schema.
///
/// For each ticket file in `.nbd/tickets/` (any supported format):
/// - Deserialise into [`Ticket`], injecting the `id` from the filename stem.
/// - If the ticket does not match `filter`, count it as skipped and continue.
/// - Re-serialise in the same format.
/// - If the bytes differ, write the new content (unless `dry_run` is `true`).
/// - If the bytes are identical, count the file as already current.
/// - If deserialisation fails, record the error and leave the file untouched.
///
/// When `dry_run` is `true`, no files are written; the report describes what
/// *would* have changed. The function always returns `Ok` — individual file
/// errors are collected in [`MigrateReport::errors`] rather than aborting early.
///
/// # Errors
///
/// Returns an error only if the tickets directory itself cannot be read.
pub async fn migrate_tickets(
root: &Path,
dry_run: bool,
filter: &TicketFilter,
) -> Result<MigrateReport> {
let dir = tickets_dir(root);
let mut report = MigrateReport {
updated: 0,
already_current: 0,
skipped: 0,
errors: Vec::new(),
};
if !dir.is_dir() {
return Ok(report);
}
let mut entries = fs::read_dir(&dir).await?;
while let Some(entry) = entries.next_entry().await? {
// Construct a std::path::PathBuf so it's compatible with Path helpers.
let path: PathBuf = dir.join(entry.file_name());
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if !KNOWN_EXTENSIONS.contains(&ext) {
continue;
}
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let raw = match fs::read(&path).await {
Ok(b) => b,
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
};
let format = detect_format(&path);
let mut ticket: Ticket = match deserialize_by_format(&raw, format) {
Ok(t) => t,
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
};
ticket.id = stem;
// Apply caller-supplied filter; skip non-matching tickets.
if !filter.matches(&ticket) {
report.skipped += 1;
continue;
}
// Re-serialise in the same format to normalise the schema.
let new_bytes: Vec<u8> = match format {
FileFormat::Json => match serialize_json(&ticket) {
Ok(s) => s.into_bytes(),
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
},
FileFormat::Markdown => match serialize_markdown(&ticket) {
Ok(s) => s.into_bytes(),
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
},
FileFormat::Toml => match serialize_toml(&ticket) {
Ok(s) => s.into_bytes(),
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
},
FileFormat::Jsonb => match serialize_jsonb(&ticket) {
Ok(b) => b,
Err(e) => {
report.errors.push((filename, e.to_string()));
continue;
}
},
};
if raw == new_bytes {
report.already_current += 1;
} else if dry_run {
report.updated += 1;
} else {
if let Err(e) = fs::write(&path, &new_bytes).await {
report.errors.push((filename, e.to_string()));
continue;
}
report.updated += 1;
}
}
Ok(report)
}
/// Read every ticket file in the tickets directory and return them sorted by
/// priority descending (highest priority first).
///
/// All supported file formats (`.json`, `.md`, `.toml`, `.jsonb`) are scanned.
/// If the tickets directory does not exist, an empty `Vec` is returned.
///
/// # Errors
///
/// Returns an error if the directory listing fails, if any ticket file cannot
/// be read, or if any ticket's content cannot be deserialised.
pub async fn list_tickets(root: &Path) -> Result<Vec<Ticket>> {
let dir = tickets_dir(root);
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut entries = fs::read_dir(&dir).await?;
let mut tickets = Vec::new();
// Guard against the same logical ticket appearing in multiple formats.
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
while let Some(entry) = entries.next_entry().await? {
// Construct a std::path::PathBuf so it's compatible with Path helpers.
let path: PathBuf = dir.join(entry.file_name());
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if !KNOWN_EXTENSIONS.contains(&ext) {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or("ticket filename has no valid stem")?
.to_string();
if !seen_ids.insert(stem.clone()) {
// Same ID already loaded from a different-extension file; skip.
continue;
}
let bytes = fs::read(&path).await?;
let format = detect_format(&path);
let mut ticket = deserialize_by_format(&bytes, format)?;
ticket.id = stem;
tickets.push(ticket);
}
// Highest priority value first; ties preserve filesystem order.
tickets.sort_by(|a, b| b.priority.cmp(&a.priority));
Ok(tickets)
}
// ── Turso (libsql) cache ──────────────────────────────────────────────────────
/// Open (or create) the libsql cache database at `.nbd/cache.db`.
///
/// Runs the schema migration on every open so the table exists when needed.
/// The returned [`libsql::Connection`] is ready for queries.
///
/// # Errors
///
/// Returns an error if the database cannot be opened or the migration fails.
async fn open_cache(root: &Path) -> Result<libsql::Connection> {
let db_path = root.join(".nbd").join("cache.db");
let db = libsql::Builder::new_local(db_path)
.build()
.await
.map_err(|e| msg_err(format!("libsql open error: {e}")))?;
let conn = db
.connect()
.map_err(|e| msg_err(format!("libsql connect error: {e}")))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
json TEXT NOT NULL,
mtime INTEGER NOT NULL
)",
(),
)
.await
.map_err(|e| msg_err(format!("libsql migrate error: {e}")))?;
Ok(conn)
}
/// Like [`list_tickets`] but uses a libsql cache in `.nbd/cache.db` to skip
/// re-reading files whose modification time has not changed.
///
/// ## Cache strategy
///
/// 1. Scan `.nbd/tickets/` for files and their mtimes.
/// 2. For each file: if the cache row exists and the mtime matches, deserialise
/// the cached JSON; otherwise read the file, parse it, and upsert the cache.
/// 3. Delete rows for IDs that no longer exist on disk.
/// 4. Return tickets sorted by priority descending.
///
/// Falls back to [`list_tickets`] if the cache cannot be opened or any cache
/// operation fails, so the caller always gets a result.
///
/// # Errors
///
/// Only returns an error if [`list_tickets`] itself fails (the fallback path).
pub async fn list_tickets_cached(root: &Path) -> Result<Vec<Ticket>> {
match list_tickets_cached_inner(root).await {
Ok(tickets) => Ok(tickets),
Err(_) => list_tickets(root).await,
}
}
/// Inner implementation of the cached list; errors cause a fallback to the
/// uncached path in [`list_tickets_cached`].
async fn list_tickets_cached_inner(root: &Path) -> Result<Vec<Ticket>> {
let dir = tickets_dir(root);
if !dir.is_dir() {
return Ok(Vec::new());
}
let conn = open_cache(root).await?;
// Scan the directory, collecting (id, path, mtime_secs) for each ticket file.
let mut on_disk: Vec<(String, PathBuf, i64)> = Vec::new();
let mut read_dir = fs::read_dir(&dir).await?;
while let Some(entry) = read_dir.next_entry().await? {
let path: PathBuf = dir.join(entry.file_name());
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
if !KNOWN_EXTENSIONS.contains(&ext) {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
let meta = fs::metadata(&path).await?;
let mtime = meta
.modified()
.ok()
.and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_millis() as i64)
})
.unwrap_or(0);
on_disk.push((stem, path, mtime));
}
// Deduplicate: keep only the first occurrence of each ID (same order as list_tickets).
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
on_disk.retain(|(id, _, _)| seen_ids.insert(id.clone()));
let disk_ids: std::collections::HashSet<String> =
on_disk.iter().map(|(id, _, _)| id.clone()).collect();
let mut tickets: Vec<Ticket> = Vec::with_capacity(on_disk.len());
for (id, path, mtime) in &on_disk {
// Query the cache for this id.
let cached_json: Option<String> = {
let mut rows = conn
.query(
"SELECT json FROM tickets WHERE id = ?1 AND mtime = ?2",
libsql::params![id.as_str(), *mtime],
)
.await
.map_err(|e| msg_err(format!("cache query error: {e}")))?;
if let Some(row) = rows
.next()
.await
.map_err(|e| msg_err(format!("cache row error: {e}")))?
{
Some(
row.get::<String>(0)
.map_err(|e| msg_err(format!("cache column error: {e}")))?,
)
} else {
None
}
};
let mut ticket = if let Some(json) = cached_json {
serde_json::from_str::<Ticket>(&json)
.map_err(|e| msg_err(format!("cache deserialise error: {e}")))?
} else {
// Cache miss: read file and upsert.
let bytes = fs::read(path).await?;
let format = detect_format(path);
let t = deserialize_by_format(&bytes, format)?;
// Store normalised JSON in the cache.
let json = serde_json::to_string(&t)?;
conn.execute(
"INSERT OR REPLACE INTO tickets (id, json, mtime) VALUES (?1, ?2, ?3)",
libsql::params![id.as_str(), json.as_str(), *mtime],
)
.await
.map_err(|e| msg_err(format!("cache upsert error: {e}")))?;
t
};
ticket.id = id.clone();
tickets.push(ticket);
}
// Remove stale rows for IDs no longer on disk.
// We fetch all cached IDs and delete anything not in `disk_ids`.
let mut rows = conn
.query("SELECT id FROM tickets", ())
.await
.map_err(|e| msg_err(format!("cache scan error: {e}")))?;
let mut stale: Vec<String> = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| msg_err(format!("cache row error: {e}")))?
{
let cached_id: String = row
.get::<String>(0)
.map_err(|e| msg_err(format!("cache column error: {e}")))?;
if !disk_ids.contains(&cached_id) {
stale.push(cached_id);
}
}
for id in stale {
conn.execute(
"DELETE FROM tickets WHERE id = ?1",
libsql::params![id.as_str()],
)
.await
.map_err(|e| msg_err(format!("cache delete error: {e}")))?;
}
tickets.sort_by(|a, b| b.priority.cmp(&a.priority));
Ok(tickets)
}

File diff suppressed because it is too large Load Diff

@ -1,165 +0,0 @@
//! Core ticket data model.
//!
//! Defines the [`Ticket`] struct along with the [`Status`] and [`TicketType`]
//! enums that describe a ticket's lifecycle state and category.
//!
//! ID generation ([`generate_id`]) and priority validation
//! ([`validate_priority`]) are also provided here.
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
use serde::{Deserialize, Serialize};
/// The lifecycle status of a ticket.
///
/// Serializes to/from lowercase snake_case strings so that JSON files are
/// human-readable: `"todo"`, `"in_progress"`, `"done"`, `"closed"`,
/// `"archived"`, `"backlog"`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Status {
/// The ticket has not been started yet.
#[default]
Todo,
/// The ticket is actively being worked on.
InProgress,
/// The ticket has been completed.
Done,
/// The ticket will not be completed (cancelled, superseded, won't-fix).
///
/// Closed tickets count as resolved for dependency purposes — a dependent
/// ticket whose dependency is `closed` becomes unblocked. They are
/// excluded from normal `nbd list` output.
///
/// Use `nbd update <id> --status closed` to set this status, or pass
/// `--filter status=closed` (or `--all`) to make them visible in listings.
Closed,
/// The ticket was completed and soft-deleted from the active view.
///
/// Set by `nbd archive <id>`. Archived tickets count as resolved for
/// dependency purposes — a dependent ticket whose dependency is `archived`
/// becomes unblocked. They are excluded from normal `nbd list` output.
///
/// Use `--filter status=archived` or `--all` to make them visible again.
Archived,
/// The ticket is created but intentionally deferred.
///
/// Backlog tickets are excluded from `nbd list`, `nbd ready`, and
/// `nbd next` by default. Unlike `done`, `closed`, and `archived`, they
/// do **not** count as resolved for dependency purposes — a dependent
/// ticket whose dependency is `backlog` is still blocked.
///
/// Use `--filter status=backlog` or `--all` to make them visible.
Backlog,
}
/// The category of a ticket.
///
/// Serializes to/from lowercase strings: `"project"`, `"feature"`, `"task"`,
/// `"bug"`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum TicketType {
/// A high-level grouping of related work.
Project,
/// A new capability or user-facing behaviour to be added.
Feature,
/// A discrete, self-contained unit of work.
#[default]
Task,
/// A defect or unintended behaviour to be fixed.
Bug,
}
/// A single work ticket.
///
/// Tickets are identified by a 6-character lowercase hex string and stored as
/// JSON files at `.nbd/tickets/{id}.json` relative to the project root.
///
/// The `id` field is **not** stored in the JSON file — the filename stem is
/// the sole source of truth. After deserialising, callers in [`crate::store`]
/// inject the correct id from the filename.
///
/// Use [`Ticket::new`] to create a ticket with sensible defaults, then
/// customise individual fields before persisting with `store::write_ticket`.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Ticket {
/// Unique 6-character lowercase hex identifier, e.g. `"a3f9c2"`.
///
/// Not serialised to JSON — the filename stem is the canonical source of
/// truth and this field is populated by [`crate::store`] at read time.
#[serde(skip)]
pub id: String,
/// Short, human-readable summary of the work to be done.
pub title: String,
/// Optional long-form description. Empty string when not provided.
pub body: String,
/// Relative importance on a scale of 010, inclusive. Default is `5`.
/// Higher values indicate greater urgency or value.
pub priority: u8,
/// Current lifecycle phase of the ticket.
pub status: Status,
/// IDs of tickets that must be completed before this one can start.
pub dependencies: Vec<String>,
/// Broad category of the work described by this ticket.
pub ticket_type: TicketType,
}
impl Ticket {
/// Create a new ticket with the given `id` and `title`.
///
/// All remaining fields are initialised to their defaults:
/// - `body` — empty string
/// - `priority` — `5`
/// - `status` — [`Status::Todo`]
/// - `dependencies` — empty `Vec`
/// - `ticket_type` — [`TicketType::Task`]
pub fn new(id: String, title: String) -> Self {
Ticket {
id,
title,
body: String::new(),
priority: 5,
status: Status::default(),
dependencies: Vec::new(),
ticket_type: TicketType::default(),
}
}
}
/// Validate that `priority` is within the allowed range of `0..=10`.
///
/// Returns `Ok(())` when valid, or an `Err` with a descriptive message when
/// the value exceeds `10`.
pub fn validate_priority(priority: u8) -> Result<(), String> {
if priority > 10 {
Err(format!(
"priority must be between 0 and 10 inclusive, got {priority}"
))
} else {
Ok(())
}
}
/// Generate a random 6-character lowercase hex ticket ID.
///
/// Internally uses [`RandomState`], which is seeded with OS randomness on
/// every construction, to produce 3 pseudo-random bytes that are formatted as
/// a 6-character hex string.
///
/// The result is always exactly 6 characters and contains only the characters
/// `09` and `af`.
pub fn generate_id() -> String {
// RandomState::new() is seeded with OS randomness on each call, so the
// resulting hasher produces different output every time even with the
// same input data.
let state = RandomState::new();
let mut hasher = state.build_hasher();
// Write a fixed byte so the hasher runs through its full compression
// function rather than relying solely on the finalisation step.
hasher.write_u8(0);
let hash = hasher.finish();
// Mask to 24 bits (3 bytes) → 6 hex characters.
format!("{:06x}", hash & 0x00FF_FFFF)
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save