Migrate all tickets to `.md` format

quotesdb
Elijah Voigt 3 months ago
parent 0bd7bd8c0f
commit 9a58e78ca8

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

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

@ -0,0 +1,50 @@
+++
title = "nbd archive command and Closed status"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = []
+++
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,8 +0,0 @@
{
"title": "Multiple file format support (md, toml, jsonb)",
"body": "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.\n\n## Motivation\n\nMarkdown 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.\n\n## Approach\n\n### New crate dependencies (Cargo.toml)\nEvaluate and add:\n- `toml` — TOML serialisation (likely `toml = \"0.8\"`)\n- `serde_yml` or `serde_yaml` — YAML frontmatter (for `.md` files)\n- `ciborium` — CBOR binary JSON (`.jsonb`)\n\n### ticket.rs\nNo changes needed — `Ticket` already derives `Serialize`/`Deserialize`.\n\n### store.rs\nNew enum `FileFormat { Json, Markdown, Toml, Jsonb }`.\n\nNew function `detect_format(path: &Path) -> FileFormat`:\n- `.json` → `Json`\n- `.md` → `Markdown`\n- `.toml` → `Toml`\n- `.jsonb` → `Jsonb`\n- Unknown → `Json` (fallback)\n\nUpdate `ticket_path(root, id, format)` to use the format-appropriate extension. This is a breaking change to the function signature — update all callers.\n\nUpdate `read_ticket(root, id)`:\n1. Try each known extension in order until a file is found.\n2. Read the file and dispatch to the format-appropriate deserialiser.\n\nSerialisation helpers (private):\n- `serialize_json(ticket) -> String`\n- `serialize_toml(ticket) -> String`\n- `serialize_markdown(ticket) -> String` — TOML frontmatter (`+++` delimiters) with body as file content\n- `serialize_jsonb(ticket) -> Vec<u8>`\n\nDeserialization helpers (private):\n- `deserialize_markdown(bytes) -> Result<Ticket>` — parse frontmatter + body\n\nUpdate `list_tickets` to scan for `*.json`, `*.md`, `*.toml`, `*.jsonb` files.\n\nUpdate `write_ticket` to accept `format: FileFormat` and write in the appropriate format.\n\n### main.rs\nAdd `--ftype [json|md|toml|jsonb]` option (default `json`) to `create` and `update`.\nConversion on `update --ftype`: read old file, write new format, delete old file (if extension changed).\n\n## Markdown format (TOML frontmatter)\n```\n+++\nid = \"a3f9c2\"\ntitle = \"Fix login bug\"\npriority = 8\nstatus = \"in_progress\"\nticket_type = \"bug\"\ndependencies = [\"b7d41e\"]\n+++\n\nLong-form body text goes here. Supports full markdown.\n```\n\n## Tests\n- Unit tests: roundtrip each format (JSON already tested).\n- Integration tests: `nbd create --ftype md` creates a `.md` file; `nbd read` finds and parses it.\n- Integration test: `nbd update <id> --ftype toml` converts format and removes old file.\n\n## Files touched\n- `Cargo.toml` — new dependencies\n- `src/store.rs` — format detection, multi-format read/write, updated `list_tickets`\n- `src/main.rs` — `--ftype` flags\n- `src/tests.rs` — format roundtrip tests\n- `tests/integration.rs` — format integration tests\n- `README.md` — document `--ftype`\n- `docs/ARCHITECTURE.md` — update storage layout section",
"priority": 5,
"status": "done",
"dependencies": [],
"ticket_type": "feature"
}

@ -0,0 +1,84 @@
+++
title = "Multiple file format support (md, toml, jsonb)"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
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,8 +0,0 @@
{
"title": "nbd init command",
"body": "Add an explicit `nbd init` subcommand that creates `.nbd/tickets/` in the current working directory, analogous to `git init`.\n\n## Motivation\n\nCurrently 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.\n\n## Approach\n\n1. Add `Init` variant to the `Commands` enum in `main.rs`.\n2. Implement `cmd_init(json: bool) -> store::Result<()>`:\n - Get cwd via `std::env::current_dir()`.\n - Call `store::ensure_tickets_dir(&cwd)` (already exists, idempotent).\n - Print confirmation: `initialised .nbd/tickets/ in <path>` (or JSON: `{\"root\": \"<path>\"}`).\n3. No changes to `store.rs` needed — `ensure_tickets_dir` already uses `create_dir_all` (idempotent).\n4. The command should NOT require `.nbd/` to already exist (i.e. do NOT call `find_nbd_root` here — use cwd directly).\n\n## Tests\n\n- Integration test: run `nbd init` in a fresh tempdir, verify `.nbd/tickets/` is created.\n- Integration test: run `nbd init` twice in the same dir — succeeds both times (idempotent).\n- Integration test: `nbd init --json` outputs valid JSON with a `root` field.\n\n## Files touched\n- `src/main.rs` — new `Init` variant and `cmd_init` handler\n- `tests/integration.rs` — new integration tests\n- `README.md` — update Initialise section",
"priority": 7,
"status": "done",
"dependencies": [],
"ticket_type": "feature"
}

@ -0,0 +1,33 @@
+++
title = "nbd init command"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
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,8 +0,0 @@
{
"title": "nbd update diff output",
"body": "Show a git-diff-style +/- summary of what changed when `nbd update` is run without `--json`.\n\n## Motivation\n\nCurrently `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.\n\n## Approach\n\n### display.rs\nAdd `format_diff(old: &Ticket, new: &Ticket) -> String`:\n- Compare each field between `old` and `new`.\n- For each field that changed, emit two lines:\n ```\n - status: todo\n + status: in_progress\n ```\n- If no fields changed, emit `(no changes)`.\n- Fields compared: `title`, `body`, `priority`, `status`, `ticket_type`, `dependencies`.\n- `id` is never shown (it cannot change).\n- Label width matches `format_ticket` (LABEL_WIDTH).\n\nAdd `print_diff(old: &Ticket, new: &Ticket)` that calls `println!(\"{}\" format_diff(...))`.\n\n### main.rs\nIn `cmd_update`:\n- Before applying changes: `let old = ticket.clone();`\n- After `write_ticket`:\n - If `json`: current behaviour (print new ticket as JSON).\n - Else: `display::print_diff(&old, &ticket)`.\n\n## Tests\n\nUnit tests in `src/tests.rs`:\n- `format_diff` shows changed fields only.\n- `format_diff` with identical tickets outputs `(no changes)`.\n- Changed dependencies are shown as comma-separated lists on each line.\n\nIntegration test:\n- `nbd update <id> --status in_progress` (no `--json`) prints `- status:` and `+ status:` lines.\n- `nbd update <id> --json` still prints full JSON (no diff).\n\n## Files touched\n- `src/display.rs` — `format_diff`, `print_diff`\n- `src/main.rs` — `cmd_update` uses `print_diff`\n- `src/tests.rs` — unit tests for `format_diff`\n- `tests/integration.rs` — integration tests",
"priority": 5,
"status": "todo",
"dependencies": [],
"ticket_type": "feature"
}

@ -0,0 +1,53 @@
+++
title = "nbd update diff output"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = []
+++
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,8 +0,0 @@
{
"title": "Nix flake for nbd",
"body": "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.\n\n## Motivation\n\nOther `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.\n\n## Approach\n\n### nbd/flake.nix\n\nFollow the pattern from the repo root `flake.nix`. The service flake should:\n1. Inherit the base flake's inputs (nixpkgs, rust-overlay, etc.) or declare its own.\n2. Define a `packages.default` attribute that builds the `nbd` crate with `rustPlatform.buildRustPackage`.\n3. Define a `devShells.default` that includes the `nbd` binary and standard Rust tooling (rustfmt, clippy, cargo).\n4. Optionally expose an `apps.default` for `nix run .#nbd`.\n\n### Cargo.lock\nEnsure `Cargo.lock` is committed (it already is) — required for reproducible Nix builds.\n\n### cargoHash / cargoSha256\nThe `buildRustPackage` derivation requires a `cargoHash` (or `cargoSha256`). Use the correct fetcher approach (vendored deps or `fetchCargoTarball`).\n\n## Steps\n1. Read the root `flake.nix` to understand the base structure.\n2. Write `nbd/flake.nix` following the same conventions.\n3. Run `nix build .#nbd` from the `nbd/` directory to verify it builds.\n4. Run `nix run .#nbd -- --help` to verify the binary works.\n\n## Files touched\n- `nbd/flake.nix` — new file\n- `README.md` — add `nix run` usage section",
"priority": 4,
"status": "todo",
"dependencies": [],
"ticket_type": "task"
}

@ -0,0 +1,38 @@
+++
title = "Nix flake for nbd"
priority = 4
status = "todo"
ticket_type = "task"
dependencies = []
+++
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,8 +0,0 @@
{
"title": "SQLite cache for list performance",
"body": "Add an optional SQLite cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores.\n\n## Motivation\n\n`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A SQLite cache avoids re-reading unchanged files by comparing file modification times (mtimes).\n\n## Approach\n\n### Crate dependency\nAdd `sqlx` with the `sqlite` and `runtime-async-std` features to `Cargo.toml`.\n\n### store.rs additions\nNew async function `open_cache(root: &Path) -> Result<sqlx::SqlitePool>`:\n- Opens (or creates) `.nbd/cache.db`.\n- Runs a migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`.\n\nNew function `list_tickets_cached(root: &Path) -> Result<Vec<Ticket>>`:\n1. Open cache.\n2. Read directory listing to get file names and mtimes.\n3. For each file: if the DB has a row with matching mtime, use cached JSON; otherwise read file, parse, insert/update row.\n4. Delete DB rows for IDs no longer on disk.\n5. Return deserialized tickets sorted by priority desc.\n\nKeep existing `list_tickets` as the non-cached fallback. Consider making `cmd_list` and `cmd_ready` use `list_tickets_cached` when available.\n\n### Migration strategy\n- The cache is always optional. If `cache.db` can't be opened, fall back to `list_tickets` (log a warning to stderr).\n- The cache is never the source of truth — the JSON files are. The cache is always reconstructable by deleting `.nbd/cache.db`.\n\n## Decision point\nDecide whether to enable the cache unconditionally or gate it behind a flag (`--cache` / `NBD_CACHE=1`). Recommendation: enable by default once the feature is stable.\n\n## Tests\n- Unit test: cache hit returns same data as direct file read.\n- Unit test: cache miss (mtime changed) re-reads the file.\n- Unit test: deleted ticket is evicted from cache.\n- Performance test (optional): benchmark 1000-ticket list with and without cache.\n\n## Files touched\n- `Cargo.toml` — add `sqlx`\n- `src/store.rs` — `open_cache`, `list_tickets_cached`\n- `src/tests.rs` — cache unit tests\n- `docs/ARCHITECTURE.md` — document the cache layer",
"priority": 3,
"status": "todo",
"dependencies": [],
"ticket_type": "feature"
}

@ -0,0 +1,50 @@
+++
title = "SQLite cache for list performance"
priority = 3
status = "todo"
ticket_type = "feature"
dependencies = []
+++
Add an optional SQLite 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 SQLite cache avoids re-reading unchanged files by comparing file modification times (mtimes).
## Approach
### Crate dependency
Add `sqlx` with the `sqlite` and `runtime-async-std` features to `Cargo.toml`.
### store.rs additions
New async function `open_cache(root: &Path) -> Result<sqlx::SqlitePool>`:
- Opens (or creates) `.nbd/cache.db`.
- Runs a 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. Open cache.
2. Read directory listing to get file names and mtimes.
3. For each file: if the DB has a row with matching mtime, use cached JSON; otherwise read file, parse, insert/update row.
4. Delete DB rows for IDs no longer on disk.
5. Return deserialized tickets sorted by priority desc.
Keep existing `list_tickets` as the non-cached fallback. Consider making `cmd_list` and `cmd_ready` use `list_tickets_cached` when available.
### Migration strategy
- The cache is always optional. If `cache.db` can't be opened, fall back to `list_tickets` (log a warning to stderr).
- The cache is never the source of truth — the JSON files are. The cache is always reconstructable by deleting `.nbd/cache.db`.
## Decision point
Decide whether to enable the cache unconditionally or gate it behind a flag (`--cache` / `NBD_CACHE=1`). Recommendation: enable by default once the feature is stable.
## Tests
- Unit test: cache hit returns same data as direct file read.
- Unit test: cache miss (mtime changed) re-reads the file.
- Unit test: deleted ticket is evicted from cache.
- Performance test (optional): benchmark 1000-ticket list with and without cache.
## Files touched
- `Cargo.toml` — add `sqlx`
- `src/store.rs``open_cache`, `list_tickets_cached`
- `src/tests.rs` — cache unit tests
- `docs/ARCHITECTURE.md` — document the cache layer

File diff suppressed because one or more lines are too long

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

@ -0,0 +1,133 @@
+++
title = "Exclude done tickets from nbd list by default"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = ["887344"]
+++
## 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

File diff suppressed because one or more lines are too long

@ -0,0 +1,132 @@
+++
title = "Implement TicketFilter module with glob matching"
priority = 8
status = "done"
ticket_type = "feature"
dependencies = []
+++
## 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,8 +0,0 @@
{
"title": "Partial ID matching",
"body": "Allow `nbd read`, `nbd update`, and dependency resolution to accept a prefix of a ticket ID (e.g. `nbd read a3f` resolves to `a3f9c2`).\n\n## Motivation\n\n6-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.\n\n## Approach\n\nAdd `resolve_id(root: &Path, id_or_prefix: &str) -> Result<String>` in `store.rs`:\n1. If `id_or_prefix` is exactly 6 characters, try `read_ticket` as-is (fast path, existing behaviour).\n2. Otherwise (or if not found), scan `.nbd/tickets/` for files whose stem starts with `id_or_prefix`.\n3. Collect all matches.\n - 0 matches → error: `\"no ticket found matching '{prefix}'\"`\n - 1 match → return the full ID\n - 2+ matches → error: `\"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ...\"`\n\nUse `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.\n\n## Tests\n\nUnit tests in `src/tests.rs`:\n- Exact 6-char match still works.\n- 3-char prefix resolves correctly.\n- Ambiguous prefix returns an error listing all matching IDs.\n- Unknown prefix returns a not-found error.\n\nIntegration tests in `tests/integration.rs`:\n- `nbd read <3-char-prefix>` resolves and prints the ticket.\n- `nbd update <3-char-prefix> --status done` succeeds.\n- Ambiguous prefix exits non-zero with an informative message.\n\n## Files touched\n- `src/store.rs` — new `resolve_id` function\n- `src/main.rs` — `cmd_read`, `cmd_update`, `validate_deps` use `resolve_id`\n- `src/tests.rs` — unit tests\n- `tests/integration.rs` — integration tests",
"priority": 8,
"status": "done",
"dependencies": [],
"ticket_type": "feature"
}

@ -0,0 +1,43 @@
+++
title = "Partial ID matching"
priority = 8
status = "done"
ticket_type = "feature"
dependencies = []
+++
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,8 +0,0 @@
{
"title": "Remove id field from ticket JSON body",
"body": "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.\n\n## Motivation\n\n- Eliminates a redundancy: filename stem already IS the id.\n- Removes the risk of id mismatch (e.g. if a file is renamed manually).\n- Simplifies the JSON schema — consumers only need the body fields, not a duplicated key.\n\n## Serde approach\n\nIn `ticket.rs`, annotate the `id` field with `#[serde(skip)]`:\n\n```rust\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct Ticket {\n #[serde(skip)]\n pub id: String,\n // ...\n}\n```\n\n`#[serde(skip)]` means:\n- **Serialise:** the `id` field is omitted from JSON output entirely.\n- **Deserialise:** the field is not read from JSON; it is initialised with `String::default()` (empty string) and must be set manually after deserialization.\n\nBecause 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.\n\n## store.rs changes\n\n`read_ticket(root, id)` — inject id from the `id` parameter after deserialising:\n```rust\nlet mut ticket: Ticket = serde_json::from_slice(&bytes)?;\nticket.id = id.to_string(); // authoritative source: the filename\nOk(ticket)\n```\n\n`list_tickets(root)` — inject id from each file's stem:\n```rust\nlet stem = path.file_stem().and_then(|s| s.to_str()).ok_or(\"invalid filename\")?;\nlet mut ticket: Ticket = serde_json::from_slice(&bytes)?;\nticket.id = stem.to_string();\ntickets.push(ticket);\n```\n\n`write_ticket` — no change needed; `#[serde(skip)]` already prevents `id` from being written.\n\n`ticket_path` — no change; it already takes `id: &str` as a separate parameter.\n\n## Impact on other tickets\n\n- The `nbd migrate` companion ticket (see deps) provides the command to scrub the old `id` field from existing files.\n- Partial ID matching (`resolve_id`) is unaffected — it works on filenames, not JSON content.\n- All display, list, and read commands continue to work; `ticket.id` is populated from the filename in every read path.\n\n## Tests\n\nUnit tests (`src/tests.rs`):\n- `write_ticket` output does NOT contain the `\"id\"` key.\n- `read_ticket` with a file that has NO `id` field correctly sets `ticket.id` from the parameter.\n- `read_ticket` with an old-format file (has `\"id\"` in JSON) still sets `ticket.id` from the parameter (ignores JSON value).\n- `list_tickets` injects correct ids from filenames for all tickets.\n- Serialisation roundtrip: write then read, id is preserved via filename not JSON.\n\nIntegration tests (`tests/integration.rs`):\n- `nbd create` output (tabular and `--json`) contains the correct ID.\n- The created `.json` file on disk does NOT contain the `\"id\"` key.\n- `nbd read <id>` displays the correct ID.\n\n## Files touched\n- `src/ticket.rs` — add `#[serde(skip)]` to `id`\n- `src/store.rs` — `read_ticket` and `list_tickets` inject id from filename\n- `src/tests.rs` — update and add unit tests\n- `tests/integration.rs` — add assertion that written files lack `\"id\"` key",
"priority": 9,
"status": "done",
"dependencies": [],
"ticket_type": "task"
}

@ -0,0 +1,80 @@
+++
title = "Remove id field from ticket JSON body"
priority = 9
status = "done"
ticket_type = "task"
dependencies = []
+++
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,8 +0,0 @@
{
"title": "nbd ready command",
"body": "Add `nbd ready` subcommand that lists tickets which are actionable right now: not yet done and with all dependencies completed.\n\n## Motivation\n\nAgent workflows need to know which tickets are unblocked. `nbd list` shows everything; `nbd ready` narrows to what can actually be started immediately.\n\n## Approach\n\n1. Add `Ready` variant to `Commands` enum in `main.rs`.\n2. Implement `cmd_ready(json: bool)`:\n a. `list_tickets(root)` to fetch all tickets.\n b. Build a set of IDs for tickets with `status == Status::Done`.\n c. Filter to tickets where:\n - `ticket.status != Status::Done` (not already finished)\n - All IDs in `ticket.dependencies` are in the done-set (or the dep doesn't exist — treat missing deps as unresolved, not ready).\n d. Print the filtered slice using existing `display::print_list` / `print_list_json`.\n3. No new store or display functions needed — reuse existing.\n\n## Edge cases\n- A ticket with no dependencies and status `todo` → ready.\n- A ticket whose dep is `in_progress` → NOT ready.\n- Missing dep ID → NOT ready (treat conservatively).\n- Empty store → returns empty list (not an error).\n\n## Tests\n\nUnit-style integration tests:\n- Three tickets: A (no deps, todo), B (dep A, todo), C (no deps, done). `nbd ready` should return only A.\n- After marking A done, `nbd ready` should return B.\n- `nbd ready --json` returns a JSON array of the ready tickets.\n\n## Files touched\n- `src/main.rs` — new `Ready` variant and `cmd_ready` handler\n- `tests/integration.rs` — new integration tests",
"priority": 7,
"status": "done",
"dependencies": [],
"ticket_type": "feature"
}

@ -0,0 +1,41 @@
+++
title = "nbd ready command"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
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,8 +0,0 @@
{
"title": "nbd claude-md command",
"body": "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!`.\n\n## Motivation\n\nEvery 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`.\n\n## Snippet file\n\nCreate `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:\n\n- One-sentence description of `nbd`.\n- Initialisation (`nbd init`).\n- The four core commands with examples (`create`, `list`, `read`, `update`).\n- The `nbd ready` command for finding unblocked tickets.\n- The workflow (create before starting → set in_progress → set done).\n- Guidelines: always `--json`, priority scale, type choices, dep usage.\n\nThe 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.\n\n## Binary embedding\n\nIn `main.rs`, embed the snippet at compile time:\n\n```rust\nconst CLAUDE_MD_SNIPPET: &str = include_str!(\"claude_md_snippet.md\");\n```\n\n`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.\n\n## Command implementation\n\nAdd `ClaudeMd` variant to `Commands` in `main.rs`:\n\n```rust\n/// Print a CLAUDE.md snippet for adopting nbd in a project.\nClaudeMd,\n```\n\nThe `--json` global flag applies:\n- Without `--json`: `print!(\"{CLAUDE_MD_SNIPPET}\")` — raw markdown, suitable for redirect (`nbd claude-md >> CLAUDE.md`).\n- With `--json`: output `{\"snippet\": \"<escaped content>\"}` — for programmatic consumption.\n\nNo store access needed — this command is pure output from the embedded constant. It does not call `find_nbd_root()`.\n\n## No display.rs changes needed\n\nThe output is a single `print!` call in the command handler. No tabular formatting.\n\n## Tests\n\nIntegration tests (`tests/integration.rs`):\n- `nbd claude-md` exits zero and stdout is non-empty.\n- stdout contains key strings (`\"nbd\"`, `\"CLAUDE.md\"` or similar section header, `\"--json\"`).\n- `nbd claude-md --json` exits zero and stdout is valid JSON with a `\"snippet\"` key whose value is a non-empty string.\n- `nbd claude-md` works even when run from a directory with no `.nbd/` (no `find_nbd_root` call).\n\n## Files touched\n- `src/claude_md_snippet.md` — new file; the canonical snippet content\n- `src/main.rs` — `include_str!` constant, `ClaudeMd` command variant and handler\n- `tests/integration.rs` — integration tests\n- `README.md` — mention `nbd claude-md` in the Usage section",
"priority": 6,
"status": "done",
"dependencies": [],
"ticket_type": "feature"
}

@ -0,0 +1,68 @@
+++
title = "nbd claude-md command"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = []
+++
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

File diff suppressed because one or more lines are too long

@ -0,0 +1,179 @@
+++
title = "Add nbd next subcommand"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = ["887344"]
+++
## 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).
Loading…
Cancel
Save