From 2250aef42d990bc066c9df0e87ace30650cf56ab Mon Sep 17 00:00:00 2001 From: Erik Aker Date: Sat, 2 May 2026 09:21:47 -0700 Subject: [PATCH 1/8] Shorter ergonomic commands for all the issue stuff --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- docs/agents.md | 46 ++++++------ docs/commands.md | 147 ++++++++++++++++++++------------------ src/cli/agent_init.rs | 119 +++++++++++++++++++----------- src/cli/issue/list.rs | 6 +- src/cli/issue/move_cmd.rs | 5 +- src/cli/mod.rs | 42 ++++++++++- src/main.rs | 34 +++++++++ 10 files changed, 260 insertions(+), 147 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa55c0e..77026ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,7 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bmo" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ammonia", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 37dc131..1dc3499 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bmo" -version = "0.5.0" +version = "0.6.0" edition = "2024" rust-version = "1.91.0" description = "Local-first SQLite-backed CLI issue tracker for AI agents" diff --git a/README.md b/README.md index 7d27281..773d530 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ Download the latest binary for your platform from [GitHub Releases](https://gith ```bash bmo init -bmo issue create --title "First issue" --priority medium --kind task -bmo issue list +bmo create --title "First issue" --priority medium --kind task +bmo list bmo board bmo web ``` diff --git a/docs/agents.md b/docs/agents.md index aaa17e8..50d2487 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -68,9 +68,9 @@ bmo plan --phase 2 --json | jq '.data[].title' ```bash bmo next --json -bmo issue show BMO-7 --json -bmo issue comment list BMO-7 # always check comments — they may supersede the description -bmo issue file list BMO-7 # check attached files before starting +bmo show BMO-7 --json +bmo comment list BMO-7 # always check comments — they may supersede the description +bmo file list BMO-7 # check attached files before starting ``` ## Creating Issues @@ -78,7 +78,7 @@ bmo issue file list BMO-7 # check attached files before starting Use `-d` to provide a rich description so any agent or human reading the issue later has full context: ```bash -bmo issue create -t "Implement retry logic" \ +bmo create -t "Implement retry logic" \ -d "Add exponential backoff to the HTTP client. Max 3 retries. See src/client.rs." \ -p high -T task ``` @@ -86,39 +86,39 @@ bmo issue create -t "Implement retry logic" \ Attach all files the issue affects immediately after creation: ```bash -bmo issue file add BMO-7 src/client.rs -bmo issue file add BMO-7 src/client_test.rs +bmo file add BMO-7 src/client.rs +bmo file add BMO-7 src/client_test.rs ``` ## Tracking Progress -Use `bmo issue claim` to atomically take ownership of an issue. It sets the status to +Use `bmo claim` to atomically take ownership of an issue. It sets the status to `in-progress` and optionally records an assignee in a single operation. If another agent has already claimed the ticket, it returns a conflict error (exit code 4) rather than overwriting — making it safe for concurrent multi-agent workflows. ```bash -bmo issue claim BMO-7 # atomic: sets in-progress, fails if already claimed -bmo issue claim BMO-7 --assignee alice --json +bmo claim BMO-7 # atomic: sets in-progress, fails if already claimed +bmo claim BMO-7 --assignee alice --json ``` -`bmo issue claim` replaces the older two-step `move + edit` pattern. The old pattern still +`bmo claim` replaces the older two-step `move + edit` pattern. The old pattern still works but `claim` is preferred when multiple agents may be picking up work simultaneously. If the claim response includes a `"file_conflicts"` key, another in-progress issue shares the same file attachments. Check for conflicts before beginning implementation: ```bash -bmo issue claim BMO-7 --json | jq '.file_conflicts' -bmo issue file conflicts BMO-7 --json # also callable independently +bmo claim BMO-7 --json | jq '.file_conflicts' +bmo file conflicts BMO-7 --json # also callable independently ``` Once work is complete, close the issue: ```bash -bmo issue move BMO-7 --status done +bmo move BMO-7 --status done # or equivalently: -bmo issue close BMO-7 +bmo close BMO-7 ``` ## Adding Context via Comments @@ -139,16 +139,16 @@ so other agents can scan comments efficiently: | `HANDOFF:` | any | Work complete, context for the next agent | ```bash -bmo issue comment add BMO-7 --body "FINDING: the HTTP client also needs connection timeout handling. Needs a follow-up issue." -bmo issue comment add BMO-7 --body "DECISION: used exponential backoff with jitter rather than fixed intervals." -bmo issue comment add BMO-7 --body "HANDOFF: retry logic complete. Tests in src/client_test.rs. Next agent should add integration test." +bmo comment add BMO-7 --body "FINDING: the HTTP client also needs connection timeout handling. Needs a follow-up issue." +bmo comment add BMO-7 --body "DECISION: used exponential backoff with jitter rather than fixed intervals." +bmo comment add BMO-7 --body "HANDOFF: retry logic complete. Tests in src/client_test.rs. Next agent should add integration test." ``` Scan comments by tag before starting work: ```bash -bmo issue comment list BMO-7 --json | jq '.data[] | select(.body | startswith("HANDOFF:")) | .body' -bmo issue comment list BMO-7 --json | jq '.data[] | select(.body | startswith("BLOCKER:")) | .body' +bmo comment list BMO-7 --json | jq '.data[] | select(.body | startswith("HANDOFF:")) | .body' +bmo comment list BMO-7 --json | jq '.data[] | select(.body | startswith("BLOCKER:")) | .body' ``` Comments are the canonical record of what happened. Always read them before starting work. @@ -158,9 +158,9 @@ Comments are the canonical record of what happened. Always read them before star Record which files are relevant to an issue for traceability and to enable collision detection between concurrent agents: ```bash -bmo issue file add BMO-7 src/client.rs -bmo issue file list BMO-7 -bmo issue file conflicts BMO-7 --json # check for overlaps with other in-progress work +bmo file add BMO-7 src/client.rs +bmo file list BMO-7 +bmo file conflicts BMO-7 --json # check for overlaps with other in-progress work ``` ## Reading JSON Output @@ -171,7 +171,7 @@ to Python if `jq` is unavailable. ```bash # Preferred: jq bmo next --json | jq '.data[] | {id: .id, title: .title}' -bmo issue show BMO-7 --json | jq '.data.issue.status' +bmo show BMO-7 --json | jq '.data.issue.status' bmo board --json | jq '.data.in_progress[].id' bmo plan --phase 1 --json | jq '.data[].id' diff --git a/docs/commands.md b/docs/commands.md index b6ad933..d635145 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -364,13 +364,15 @@ bmo web --host 0.0.0.0 --port 7777 ## bmo issue -Manage issues. All issue operations are subcommands of `bmo issue`. +Manage issues. Issue subcommands can be invoked directly as `bmo ` (e.g., `bmo list`, +`bmo show BMO-1`) or with the explicit prefix `bmo issue ` — both forms are equivalent. +The `bmo issue` prefix is kept for backward compatibility. ### bmo issue create Create a new issue. -**Synopsis:** `bmo issue create --title [options]` +**Synopsis:** `bmo create --title <title> [options]` | Flag | Type | Default | Description | |------|------|---------|-------------| @@ -387,9 +389,10 @@ Create a new issue. **Examples:** ``` +bmo create --title "Fix login bug" --kind bug --priority high bmo issue create --title "Fix login bug" --kind bug --priority high -bmo issue create -t "Add tests" -T task --parent BMO-1 --label testing -bmo issue create --title "Epic: v2 launch" --kind epic --json +bmo create -t "Add tests" -T task --parent BMO-1 --label testing +bmo create --title "Epic: v2 launch" --kind epic --json ``` **JSON output** (`data` field): a single issue object. @@ -398,33 +401,35 @@ bmo issue create --title "Epic: v2 launch" --kind epic --json List issues with optional filters. Excludes done issues by default. -**Synopsis:** `bmo issue list [options]` +**Synopsis:** `bmo list [options]` -Alias: `bmo issue ls` +Aliases: `bmo ls`, `bmo issue list`, `bmo issue ls` | Flag | Type | Default | Description | |------|------|---------|-------------| | `-s, --status <status>` | string (repeatable) | (none) | Filter by status | | `-p, --priority <priority>` | string (repeatable) | (none) | Filter by priority | -| `-T, --kind <kind>` | string (repeatable) | (none) | Filter by kind | -| `-a, --assignee <name>` | string | (none) | Filter by assignee | +| `-k, --kind <kind>` | string (repeatable) | (none) | Filter by kind | +| `--assignee <name>` | string | (none) | Filter by assignee | | `-l, --label <label>` | string (repeatable) | (none) | Filter by label (AND semantics) | | `--parent <id>` | string | (none) | Filter to children of this issue | | `--search <text>` | string | (none) | Full-text search in title and description | | `--limit <n>` | integer | 50 | Maximum number of results | | `--sort <field>` | string | (none) | Sort field | | `--include-done` | bool | false | Removes the default `status != done` exclusion; done issues are returned alongside active ones. All other filters (priority, kind, labels, etc.) remain active. Distinct from `--all` which short-circuits every predicate. | -| `--all` | bool | false | Return all issues regardless of status or other filters (short-circuits all predicates) | +| `-a, --all` | bool | false | Return all issues regardless of status or other filters (short-circuits all predicates) | | `--oneline` | bool | false | Print one compact line per issue (ID, status, priority, kind, title) | **Examples:** ``` +bmo list bmo issue list -bmo issue list --status in-progress --assignee alice -bmo issue list --kind bug --priority high --all --json -bmo issue list --search "login" --limit 10 -bmo issue list --include-done --priority high +bmo list --status in-progress --assignee alice +bmo list --kind bug --priority high --all --json +bmo list -k bug -p high -a --json +bmo list --search "login" --limit 10 +bmo list --include-done --priority high ``` **JSON output** (`data` field): array of issue objects. @@ -433,7 +438,7 @@ bmo issue list --include-done --priority high Show full details for a single issue, including sub-issues, relations, comments, and labels. -**Synopsis:** `bmo issue show <id>` +**Synopsis:** `bmo show <id>` | Argument | Type | Description | |----------|------|-------------| @@ -442,8 +447,8 @@ Show full details for a single issue, including sub-issues, relations, comments, **Examples:** ``` -bmo issue show BMO-5 -bmo issue show 5 --json +bmo show BMO-5 +bmo show 5 --json ``` **JSON output** (`data` field): @@ -462,7 +467,7 @@ bmo issue show 5 --json Edit one or more fields on an existing issue. Only the fields you supply are updated. -**Synopsis:** `bmo issue edit <id> [options]` +**Synopsis:** `bmo edit <id> [options]` | Argument/Flag | Type | Default | Description | |---------------|------|---------|-------------| @@ -478,9 +483,9 @@ Edit one or more fields on an existing issue. Only the fields you supply are upd **Examples:** ``` -bmo issue edit BMO-3 --status in-progress -bmo issue edit 7 --title "Updated title" --priority high --json -bmo issue edit BMO-10 --assignee bob --parent BMO-1 +bmo edit BMO-3 --status in-progress +bmo edit 7 --title "Updated title" --priority high --json +bmo edit BMO-10 --assignee bob --parent BMO-1 ``` **JSON output** (`data` field): the updated issue object. @@ -489,7 +494,7 @@ bmo issue edit BMO-10 --assignee bob --parent BMO-1 Change an issue's status. -**Synopsis:** `bmo issue move <id> --status <status>` +**Synopsis:** `bmo move <id> --status <status>` | Argument/Flag | Type | Default | Description | |---------------|------|---------|-------------| @@ -499,8 +504,8 @@ Change an issue's status. **Examples:** ``` -bmo issue move BMO-4 --status in-progress -bmo issue move 4 --status review --json +bmo move BMO-4 --status in-progress +bmo move 4 --status review --json ``` **JSON output** (`data` field): the updated issue object. @@ -509,7 +514,7 @@ bmo issue move 4 --status review --json Mark an issue as done (sets status to `done`). -**Synopsis:** `bmo issue close <id>` +**Synopsis:** `bmo close <id>` | Argument | Type | Description | |----------|------|-------------| @@ -518,8 +523,8 @@ Mark an issue as done (sets status to `done`). **Example:** ``` -bmo issue close BMO-5 -bmo issue close 5 --json +bmo close BMO-5 +bmo close 5 --json ``` **JSON output** (`data` field): the updated issue object. @@ -528,7 +533,7 @@ bmo issue close 5 --json Reopen a closed issue (sets status to `todo`). -**Synopsis:** `bmo issue reopen <id>` +**Synopsis:** `bmo reopen <id>` | Argument | Type | Description | |----------|------|-------------| @@ -537,7 +542,7 @@ Reopen a closed issue (sets status to `todo`). **Example:** ``` -bmo issue reopen BMO-5 +bmo reopen BMO-5 ``` **JSON output** (`data` field): the updated issue object. @@ -548,7 +553,7 @@ Atomically claim an issue: sets status to `in-progress` and optionally records a in a single conditional SQL UPDATE. If another agent has already claimed the ticket, returns a `conflict` error rather than overwriting. -**Synopsis:** `bmo issue claim <id> [--assignee <name>]` +**Synopsis:** `bmo claim <id> [--assignee <name>]` | Argument/Flag | Type | Default | Description | |---------------|------|---------|-------------| @@ -566,8 +571,8 @@ a `conflict` error rather than overwriting. **Examples:** ``` -bmo issue claim BMO-7 -bmo issue claim BMO-7 --assignee alice --json +bmo claim BMO-7 +bmo claim BMO-7 --assignee alice --json ``` **JSON output (`data` field):** the updated issue object. @@ -583,7 +588,7 @@ includes a top-level `"file_conflicts"` key (alongside `ok`, `data`, `message`): Permanently delete an issue. Prompts for confirmation unless `--yes` is passed. -**Synopsis:** `bmo issue delete <id> [--yes]` +**Synopsis:** `bmo delete <id> [--yes]` | Argument/Flag | Type | Default | Description | |---------------|------|---------|-------------| @@ -593,8 +598,8 @@ Permanently delete an issue. Prompts for confirmation unless `--yes` is passed. **Examples:** ``` -bmo issue delete BMO-9 -bmo issue delete 9 --yes --json +bmo delete BMO-9 +bmo delete 9 --yes --json ``` **JSON output** (`data` field): `null`. @@ -603,7 +608,7 @@ bmo issue delete 9 --yes --json Show the activity log for an issue. -**Synopsis:** `bmo issue log <id> [--limit <n>]` +**Synopsis:** `bmo log <id> [--limit <n>]` | Argument/Flag | Type | Default | Description | |---------------|------|---------|-------------| @@ -613,8 +618,8 @@ Show the activity log for an issue. **Examples:** ``` -bmo issue log BMO-3 -bmo issue log 3 --limit 20 --json +bmo log BMO-3 +bmo log 3 --limit 20 --json ``` **JSON output** (`data` field): array of activity entry objects. @@ -623,7 +628,7 @@ bmo issue log 3 --limit 20 --json Show the blocking/blocked-by dependency graph for an issue. -**Synopsis:** `bmo issue graph <id>` +**Synopsis:** `bmo graph <id>` | Argument | Type | Description | |----------|------|-------------| @@ -632,8 +637,8 @@ Show the blocking/blocked-by dependency graph for an issue. **Example:** ``` -bmo issue graph BMO-1 -bmo issue graph 1 --json +bmo graph BMO-1 +bmo graph 1 --json ``` **JSON output** (`data` field): @@ -649,7 +654,7 @@ bmo issue graph 1 --json Add a comment to an issue. -**Synopsis:** `bmo issue comment add <id> --body <text> [--author <name>]` +**Synopsis:** `bmo comment add <id> --body <text> [--author <name>]` | Argument/Flag | Type | Default | Description | |---------------|------|---------|-------------| @@ -660,8 +665,8 @@ Add a comment to an issue. **Examples:** ``` -bmo issue comment add BMO-3 --body "Investigating now" -bmo issue comment add 3 --body "Fixed in commit abc123" --author alice --json +bmo comment add BMO-3 --body "Investigating now" +bmo comment add 3 --body "Fixed in commit abc123" --author alice --json ``` **JSON output** (`data` field): the created comment object. @@ -670,7 +675,7 @@ bmo issue comment add 3 --body "Fixed in commit abc123" --author alice --json List all comments on an issue. -**Synopsis:** `bmo issue comment list <id>` +**Synopsis:** `bmo comment list <id>` | Argument | Type | Description | |----------|------|-------------| @@ -679,7 +684,7 @@ List all comments on an issue. **Example:** ``` -bmo issue comment list BMO-3 --json +bmo comment list BMO-3 --json ``` **JSON output** (`data` field): array of comment objects. @@ -688,7 +693,7 @@ bmo issue comment list BMO-3 --json Add a label to an issue. Creates the label if it does not already exist. -**Synopsis:** `bmo issue label add <id> <name> [--color <hex>]` +**Synopsis:** `bmo label add <id> <name> [--color <hex>]` | Argument/Flag | Type | Default | Description | |---------------|------|---------|-------------| @@ -699,8 +704,8 @@ Add a label to an issue. Creates the label if it does not already exist. **Examples:** ``` -bmo issue label add BMO-2 bug -bmo issue label add BMO-2 urgent --color "#ff0000" --json +bmo label add BMO-2 bug +bmo label add BMO-2 urgent --color "#ff0000" --json ``` **JSON output** (`data` field): the label object. @@ -709,7 +714,7 @@ bmo issue label add BMO-2 urgent --color "#ff0000" --json Remove a label from an issue. The label itself is not deleted from the system. -**Synopsis:** `bmo issue label rm <id> <name>` +**Synopsis:** `bmo label rm <id> <name>` Alias: `bmo issue label remove` @@ -721,7 +726,7 @@ Alias: `bmo issue label remove` **Example:** ``` -bmo issue label rm BMO-2 bug +bmo label rm BMO-2 bug ``` **JSON output** (`data` field): `null`. @@ -730,7 +735,7 @@ bmo issue label rm BMO-2 bug List all labels attached to an issue. -**Synopsis:** `bmo issue label list <id>` +**Synopsis:** `bmo label list <id>` | Argument | Type | Description | |----------|------|-------------| @@ -739,7 +744,7 @@ List all labels attached to an issue. **Example:** ``` -bmo issue label list BMO-2 --json +bmo label list BMO-2 --json ``` **JSON output** (`data` field): array of label objects. @@ -748,7 +753,7 @@ bmo issue label list BMO-2 --json Delete a label from the system entirely, removing it from all issues. -**Synopsis:** `bmo issue label delete <name>` +**Synopsis:** `bmo label delete <name>` | Argument | Type | Description | |----------|------|-------------| @@ -757,7 +762,7 @@ Delete a label from the system entirely, removing it from all issues. **Example:** ``` -bmo issue label delete obsolete-label +bmo label delete obsolete-label ``` **JSON output** (`data` field): `null`. @@ -766,7 +771,7 @@ bmo issue label delete obsolete-label Add a directional relationship between two issues. -**Synopsis:** `bmo issue link add <from-id> <relation> <to-id>` +**Synopsis:** `bmo link add <from-id> <relation> <to-id>` | Argument | Type | Description | |----------|------|-------------| @@ -777,8 +782,8 @@ Add a directional relationship between two issues. **Examples:** ``` -bmo issue link add BMO-1 blocks BMO-2 -bmo issue link add 3 depends-on 1 --json +bmo link add BMO-1 blocks BMO-2 +bmo link add 3 depends-on 1 --json ``` **JSON output** (`data` field): the created relation object. @@ -787,7 +792,7 @@ bmo issue link add 3 depends-on 1 --json Remove a relation by its numeric relation ID (not an issue ID). -**Synopsis:** `bmo issue link remove <relation-id>` +**Synopsis:** `bmo link remove <relation-id>` | Argument | Type | Description | |----------|------|-------------| @@ -796,7 +801,7 @@ Remove a relation by its numeric relation ID (not an issue ID). **Example:** ``` -bmo issue link remove 7 +bmo link remove 7 ``` **JSON output** (`data` field): `null`. @@ -805,7 +810,7 @@ bmo issue link remove 7 List all relations for an issue. -**Synopsis:** `bmo issue link list <id>` +**Synopsis:** `bmo link list <id>` | Argument | Type | Description | |----------|------|-------------| @@ -814,7 +819,7 @@ List all relations for an issue. **Example:** ``` -bmo issue link list BMO-1 --json +bmo link list BMO-1 --json ``` **JSON output** (`data` field): array of relation objects. @@ -823,7 +828,7 @@ bmo issue link list BMO-1 --json Attach a file path to an issue. -**Synopsis:** `bmo issue file add <id> <path>` +**Synopsis:** `bmo file add <id> <path>` | Argument | Type | Description | |----------|------|-------------| @@ -833,8 +838,8 @@ Attach a file path to an issue. **Example:** ``` -bmo issue file add BMO-3 src/main.rs -bmo issue file add 3 docs/spec.md --json +bmo file add BMO-3 src/main.rs +bmo file add 3 docs/spec.md --json ``` **JSON output** (`data` field): the created file attachment object. @@ -843,7 +848,7 @@ bmo issue file add 3 docs/spec.md --json Remove a file attachment from an issue. -**Synopsis:** `bmo issue file rm <id> <path>` +**Synopsis:** `bmo file rm <id> <path>` Alias: `bmo issue file remove` @@ -855,7 +860,7 @@ Alias: `bmo issue file remove` **Example:** ``` -bmo issue file rm BMO-3 src/main.rs +bmo file rm BMO-3 src/main.rs ``` **JSON output** (`data` field): `null`. @@ -864,7 +869,7 @@ bmo issue file rm BMO-3 src/main.rs List all file attachments on an issue. -**Synopsis:** `bmo issue file list <id>` +**Synopsis:** `bmo file list <id>` | Argument | Type | Description | |----------|------|-------------| @@ -873,7 +878,7 @@ List all file attachments on an issue. **Example:** ``` -bmo issue file list BMO-3 --json +bmo file list BMO-3 --json ``` **JSON output** (`data` field): array of file attachment objects. @@ -883,7 +888,7 @@ bmo issue file list BMO-3 --json Show file conflicts: other in-progress issues that share one or more file attachments with the given issue. -**Synopsis:** `bmo issue file conflicts <id>` +**Synopsis:** `bmo file conflicts <id>` | Argument | Type | Description | |----------|------|-------------| @@ -894,8 +899,8 @@ Exit code is always `0` — conflict presence is information, not an error. **Example:** ``` -bmo issue file conflicts BMO-7 -bmo issue file conflicts BMO-7 --json +bmo file conflicts BMO-7 +bmo file conflicts BMO-7 --json ``` **JSON output** (`data` field): array of conflict objects, empty array when no conflicts. diff --git a/src/cli/agent_init.rs b/src/cli/agent_init.rs index 8320a47..aa2c873 100644 --- a/src/cli/agent_init.rs +++ b/src/cli/agent_init.rs @@ -2,7 +2,7 @@ use clap::Args; use crate::config::{Config, init_bmo_dir}; use crate::db::{Repository, open_db}; -use crate::model::{IssueFilter, Status}; +use crate::model::{IssueFilter, Kind, Status}; use crate::output::{BoardColumns, OutputMode, make_printer}; use crate::planner::dag::{Dag, find_ready}; use crate::planner::topo::topological_levels; @@ -13,22 +13,24 @@ pub struct AgentInitArgs {} pub const CHEAT_SHEET: &str = r#"## BMO Quick Reference ### Claiming & Working Issues - bmo issue claim BMO-N [--assignee <name>] # atomically claim a ticket (exits 4 if already claimed) - bmo issue show BMO-N --json # full details + comments - bmo issue file conflicts BMO-N --json # check for file overlaps with other in-progress work - bmo issue comment add BMO-N --body "..." # record findings, decisions, handoffs - bmo issue move BMO-N --status review # advance status - bmo issue close BMO-N # mark done + bmo claim BMO-N [--assignee <name>] # atomically claim a ticket (exits 4 if already claimed) + bmo show BMO-N --json # full details + comments + bmo file conflicts BMO-N --json # check for file overlaps with other in-progress work + bmo comment add BMO-N --body "..." # record findings, decisions, handoffs + bmo move BMO-N --status review # advance status + bmo close BMO-N # mark done ### Planning & Discovery - bmo agent-init --json # refresh board state (run once per session) - bmo next --json # work-ready issues (no unresolved blockers) - bmo plan --phase 1 --json # all issues in phase 1 (iterate phases 1..N) - bmo board --json # full kanban overview + bmo agent-init --json # refresh board state (run once per session) + bmo next --json # work-ready issues (no unresolved blockers) + bmo plan --phase 1 --json # all issues in phase 1 (iterate phases 1..N) + bmo board --json # full kanban overview + bmo link add BMO-N blocks BMO-M --json # declare a dependency between issues + bmo file file add BMO-N <PATH> --json # add a file to an issue ### JSON Parsing bmo next --json | jq '.data[] | {id: .id, title: .title}' - bmo issue comment list BMO-N --json | jq '.data[] | select(.body | startswith("HANDOFF:")) | .body' + bmo comment list BMO-N --json | jq '.data[] | select(.body | startswith("HANDOFF:")) | .body' ### Comment Tags (prefix every agent comment with the appropriate tag) BLOCKER: — work cannot proceed without resolution (any agent) @@ -53,40 +55,46 @@ pub fn run(_args: &AgentInitArgs, json: bool) -> anyhow::Result<()> { // 2. config let config = Config::load(&bmo_dir)?; - // 3. board - let board_filter = crate::filter::FilterBuilder { - findall: true, - limit: 500, + // 3. board — active issues (non-done) + most recent 10 done + let active_issues = repo.list_issues(IssueFilter::default())?; + let mut done_issues = repo.list_issues(IssueFilter { + status: Some(vec![Status::Done]), ..Default::default() - } - .build()?; - let all_issues_for_board = repo.list_issues(board_filter)?; + })?; + done_issues.sort_by_key(|b| std::cmp::Reverse(b.id)); + done_issues.truncate(10); + + // Most recent epic across all statuses + let mut all_epics = repo.list_issues(IssueFilter { + include_done: true, + kind: Some(vec![Kind::Epic]), + ..Default::default() + })?; + all_epics.sort_by_key(|b| std::cmp::Reverse(b.id)); + let recent_epic = all_epics.into_iter().next(); + let board = BoardColumns { - backlog: all_issues_for_board + backlog: active_issues .iter() .filter(|i| i.status == Status::Backlog) .cloned() .collect(), - todo: all_issues_for_board + todo: active_issues .iter() .filter(|i| i.status == Status::Todo) .cloned() .collect(), - in_progress: all_issues_for_board + in_progress: active_issues .iter() .filter(|i| i.status == Status::InProgress) .cloned() .collect(), - review: all_issues_for_board + review: active_issues .iter() .filter(|i| i.status == Status::Review) .cloned() .collect(), - done: all_issues_for_board - .iter() - .filter(|i| i.status == Status::Done) - .cloned() - .collect(), + done: done_issues, }; // 4. next (unblocked, work-ready issues) @@ -113,6 +121,7 @@ pub fn run(_args: &AgentInitArgs, json: bool) -> anyhow::Result<()> { "web_port": config.web_port(), "web_host": config.web_host(), }, + "recent_epic": recent_epic, "board": board, "next": next, "stats": stats, @@ -150,6 +159,15 @@ pub fn run(_args: &AgentInitArgs, json: bool) -> anyhow::Result<()> { println!("web_host = {}", config.web_host()); println!(); + // recent epic section + println!("=== recent epic ==="); + if let Some(ref epic) = recent_epic { + printer.print_issue_list(std::slice::from_ref(epic)); + } else { + println!("(none)"); + } + println!(); + // board section println!("=== board ==="); printer.print_board(&board); @@ -182,39 +200,44 @@ fn run_with_dir_inner(bmo_dir: &std::path::Path, json: bool) -> anyhow::Result<( let config = Config::load(bmo_dir)?; - let board_filter = crate::filter::FilterBuilder { - findall: true, - limit: 500, + let active_issues = repo.list_issues(IssueFilter::default())?; + let mut done_issues = repo.list_issues(IssueFilter { + status: Some(vec![Status::Done]), ..Default::default() - } - .build()?; - let all_issues_for_board = repo.list_issues(board_filter)?; + })?; + done_issues.sort_by_key(|b| std::cmp::Reverse(b.id)); + done_issues.truncate(10); + + let mut all_epics = repo.list_issues(IssueFilter { + include_done: true, + kind: Some(vec![Kind::Epic]), + ..Default::default() + })?; + all_epics.sort_by_key(|b| std::cmp::Reverse(b.id)); + let recent_epic = all_epics.into_iter().next(); + let board = BoardColumns { - backlog: all_issues_for_board + backlog: active_issues .iter() .filter(|i| i.status == Status::Backlog) .cloned() .collect(), - todo: all_issues_for_board + todo: active_issues .iter() .filter(|i| i.status == Status::Todo) .cloned() .collect(), - in_progress: all_issues_for_board + in_progress: active_issues .iter() .filter(|i| i.status == Status::InProgress) .cloned() .collect(), - review: all_issues_for_board + review: active_issues .iter() .filter(|i| i.status == Status::Review) .cloned() .collect(), - done: all_issues_for_board - .iter() - .filter(|i| i.status == Status::Done) - .cloned() - .collect(), + done: done_issues, }; let all_issues_for_next = repo.list_issues(IssueFilter::default())?; @@ -237,6 +260,7 @@ fn run_with_dir_inner(bmo_dir: &std::path::Path, json: bool) -> anyhow::Result<( "web_port": config.web_port(), "web_host": config.web_host(), }, + "recent_epic": recent_epic, "board": board, "next": next, "stats": stats, @@ -272,6 +296,14 @@ fn run_with_dir_inner(bmo_dir: &std::path::Path, json: bool) -> anyhow::Result<( println!("web_host = {}", config.web_host()); println!(); + println!("=== recent epic ==="); + if let Some(ref epic) = recent_epic { + printer.print_issue_list(std::slice::from_ref(epic)); + } else { + println!("(none)"); + } + println!(); + println!("=== board ==="); printer.print_board(&board); println!(); @@ -309,6 +341,7 @@ mod tests { fn cheat_sheet_is_non_empty() { assert!(!CHEAT_SHEET.is_empty()); assert!(CHEAT_SHEET.contains("bmo agent-init")); + assert!(CHEAT_SHEET.contains("bmo claim")); assert!(CHEAT_SHEET.contains("HANDOFF:")); } diff --git a/src/cli/issue/list.rs b/src/cli/issue/list.rs index ed1dd90..302b631 100644 --- a/src/cli/issue/list.rs +++ b/src/cli/issue/list.rs @@ -8,7 +8,7 @@ use crate::output::{OutputMode, make_printer}; #[derive(Args)] pub struct ListArgs { /// Find everything: short-circuit all other filters and select all issues. - #[arg(long)] + #[arg(short = 'a', long = "all")] pub all: bool, /// Filter by status (repeatable) #[arg(short, long)] @@ -17,10 +17,10 @@ pub struct ListArgs { #[arg(short, long)] pub priority: Vec<String>, /// Filter by kind (repeatable) - #[arg(short = 'T', long = "kind")] + #[arg(short = 'k', long = "kind")] pub kind: Vec<String>, /// Filter by assignee - #[arg(short, long)] + #[arg(long)] pub assignee: Option<String>, /// Filter by label (AND semantics, repeatable) #[arg(short, long)] diff --git a/src/cli/issue/move_cmd.rs b/src/cli/issue/move_cmd.rs index 18dc11b..c3a1ffc 100644 --- a/src/cli/issue/move_cmd.rs +++ b/src/cli/issue/move_cmd.rs @@ -11,12 +11,13 @@ pub struct MoveArgs { /// Issue ID pub id: String, /// New status - #[arg(short, long)] + #[arg(short = 's', long = "status")] pub status: String, } pub fn run(args: &MoveArgs, json: bool) -> anyhow::Result<()> { - run_move(&args.id, &args.status, json) + let status = &args.status; + run_move(&args.id, status, json) } pub fn run_move(id_str: &str, status_str: &str, json: bool) -> anyhow::Result<()> { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 14018bf..718b99f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -31,6 +31,7 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { + // ── Non-issue top-level commands ────────────────────────────────────────── /// Initialize a new bmo project in the current directory Init(init::InitArgs), /// Show or modify project configuration @@ -55,7 +56,46 @@ pub enum Commands { Truncate(truncate::TruncateArgs), /// One-shot session orientation: init + board + next + stats + cheat sheet AgentInit(agent_init::AgentInitArgs), - /// Manage issues + + // ── Issue commands (short form: `bmo <cmd>` instead of `bmo issue <cmd>`) ─ + /// Atomically claim an issue (sets status=in-progress and assignee) + Claim(issue::claim::ClaimArgs), + /// Create a new issue + Create(issue::create::CreateArgs), + /// List issues + #[command(alias = "ls")] + List(issue::list::ListArgs), + /// Show issue details + Show(issue::show::ShowArgs), + /// Edit an issue + Edit(issue::edit::EditArgs), + /// Change an issue's status + Move(issue::move_cmd::MoveArgs), + /// Mark an issue as done + Close(issue::close::CloseArgs), + /// Reopen a closed issue + Reopen(issue::reopen::ReopenArgs), + /// Delete an issue + Delete(issue::delete::DeleteArgs), + /// Show issue activity log + Log(issue::log_cmd::LogArgs), + /// Show issue dependency graph + Graph(issue::graph::GraphArgs), + /// Manage comments + #[command(subcommand)] + Comment(issue::comment::CommentCommands), + /// Manage labels + #[command(subcommand)] + Label(issue::label::LabelCommands), + /// Manage issue relations + #[command(subcommand)] + Link(issue::link::LinkCommands), + /// Manage attached files + #[command(subcommand)] + File(issue::file_cmd::FileCommands), + + // ── Long form (backward compatible): `bmo issue <cmd>` ─────────────────── + /// Manage issues (use `bmo <cmd>` directly for brevity) #[command(subcommand)] Issue(issue::IssueCommands), } diff --git a/src/main.rs b/src/main.rs index 89d15ba..0f3e3f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,40 @@ fn dispatch(command: Commands, json: bool, db: Option<String>) -> anyhow::Result Commands::Web(args) => cli::web::run(&args, json, db), Commands::Truncate(args) => cli::truncate::run(&args, json, db), Commands::AgentInit(args) => cli::agent_init::run(&args, json), + // Issue commands at top level (short form: `bmo <cmd>`) + Commands::Claim(args) => cli::issue::claim::run(&args, json), + Commands::Create(args) => cli::issue::create::run(&args, json), + Commands::List(args) => cli::issue::list::run(&args, json), + Commands::Show(args) => cli::issue::show::run(&args, json), + Commands::Edit(args) => cli::issue::edit::run(&args, json), + Commands::Move(args) => cli::issue::move_cmd::run(&args, json), + Commands::Close(args) => cli::issue::close::run(&args, json), + Commands::Reopen(args) => cli::issue::reopen::run(&args, json), + Commands::Delete(args) => cli::issue::delete::run(&args, json), + Commands::Log(args) => cli::issue::log_cmd::run(&args, json), + Commands::Graph(args) => cli::issue::graph::run(&args, json), + Commands::Comment(sub) => match sub { + CommentCommands::Add(args) => cli::issue::comment::run_add(&args, json), + CommentCommands::List(args) => cli::issue::comment::run_list(&args, json), + }, + Commands::Label(sub) => match sub { + LabelCommands::Add(args) => cli::issue::label::run_add(&args, json), + LabelCommands::Rm(args) => cli::issue::label::run_rm(&args, json), + LabelCommands::List(args) => cli::issue::label::run_list(&args, json), + LabelCommands::Delete(args) => cli::issue::label::run_delete(&args, json), + }, + Commands::Link(sub) => match sub { + LinkCommands::Add(args) => cli::issue::link::run_add(&args, json), + LinkCommands::Remove(args) => cli::issue::link::run_remove(&args, json), + LinkCommands::List(args) => cli::issue::link::run_list(&args, json), + }, + Commands::File(sub) => match sub { + FileCommands::Add(args) => cli::issue::file_cmd::run_add(&args, json), + FileCommands::Rm(args) => cli::issue::file_cmd::run_rm(&args, json), + FileCommands::List(args) => cli::issue::file_cmd::run_list(&args, json), + FileCommands::Conflicts(args) => cli::issue::file_cmd::run_conflicts(&args, json), + }, + // Long form (backward compatible): `bmo issue <cmd>` Commands::Issue(sub) => dispatch_issue(sub, json), } } From 4bca52a5edf34d5081b994366331493a510257bb Mon Sep 17 00:00:00 2001 From: Erik Aker <eraker@gmail.com> Date: Sat, 2 May 2026 09:38:13 -0700 Subject: [PATCH 2/8] rEvert unnecessary from when I thought about adding another option --- src/cli/issue/move_cmd.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli/issue/move_cmd.rs b/src/cli/issue/move_cmd.rs index c3a1ffc..18dc11b 100644 --- a/src/cli/issue/move_cmd.rs +++ b/src/cli/issue/move_cmd.rs @@ -11,13 +11,12 @@ pub struct MoveArgs { /// Issue ID pub id: String, /// New status - #[arg(short = 's', long = "status")] + #[arg(short, long)] pub status: String, } pub fn run(args: &MoveArgs, json: bool) -> anyhow::Result<()> { - let status = &args.status; - run_move(&args.id, status, json) + run_move(&args.id, &args.status, json) } pub fn run_move(id_str: &str, status_str: &str, json: bool) -> anyhow::Result<()> { From 364526b2101426f2c2d6934b6bb476ca191382fa Mon Sep 17 00:00:00 2001 From: Erik <eaker@mulliganfunding.com> Date: Sat, 2 May 2026 09:41:10 -0700 Subject: [PATCH 3/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/cli/agent_init.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/cli/agent_init.rs b/src/cli/agent_init.rs index aa2c873..06fabc8 100644 --- a/src/cli/agent_init.rs +++ b/src/cli/agent_init.rs @@ -65,13 +65,14 @@ pub fn run(_args: &AgentInitArgs, json: bool) -> anyhow::Result<()> { done_issues.truncate(10); // Most recent epic across all statuses - let mut all_epics = repo.list_issues(IssueFilter { - include_done: true, - kind: Some(vec![Kind::Epic]), - ..Default::default() - })?; - all_epics.sort_by_key(|b| std::cmp::Reverse(b.id)); - let recent_epic = all_epics.into_iter().next(); + let recent_epic = repo + .list_issues(IssueFilter { + include_done: true, + kind: Some(vec![Kind::Epic]), + ..Default::default() + })? + .into_iter() + .max_by_key(|issue| issue.id); let board = BoardColumns { backlog: active_issues From fcebeca493e2bd6b9ac19a5f7f6718baacb29cb0 Mon Sep 17 00:00:00 2001 From: Erik <eaker@mulliganfunding.com> Date: Sat, 2 May 2026 09:41:34 -0700 Subject: [PATCH 4/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/commands.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index d635145..47daacb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -426,8 +426,8 @@ Aliases: `bmo ls`, `bmo issue list`, `bmo issue ls` bmo list bmo issue list bmo list --status in-progress --assignee alice -bmo list --kind bug --priority high --all --json -bmo list -k bug -p high -a --json +bmo list --kind bug --priority high --json +bmo list --all --json bmo list --search "login" --limit 10 bmo list --include-done --priority high ``` From 3e766ef0b4530269fc1d96617744a3bf68ef5560 Mon Sep 17 00:00:00 2001 From: Erik Aker <eraker@gmail.com> Date: Sat, 2 May 2026 09:43:56 -0700 Subject: [PATCH 5/8] Update to -k all over --- docs/agents.md | 2 +- docs/commands.md | 6 +++--- src/cli/agent_init.rs | 2 +- src/cli/issue/create.rs | 2 +- src/cli/issue/edit.rs | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 50d2487..fa9665b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -80,7 +80,7 @@ Use `-d` to provide a rich description so any agent or human reading the issue l ```bash bmo create -t "Implement retry logic" \ -d "Add exponential backoff to the HTTP client. Max 3 retries. See src/client.rs." \ - -p high -T task + -p high -k task ``` Attach all files the issue affects immediately after creation: diff --git a/docs/commands.md b/docs/commands.md index 47daacb..647c932 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -380,7 +380,7 @@ Create a new issue. | `-d, --description <text>` | string | `""` | Issue description | | `-s, --status <status>` | string | `backlog` | Initial status | | `-p, --priority <priority>` | string | `medium` | Priority | -| `-T, --kind <kind>` | string | `task` | Issue kind/type | +| `-k, --kind <kind>` | string | `task` | Issue kind/type | | `-a, --assignee <name>` | string | (none) | Assignee | | `--parent <id>` | string | (none) | Parent issue ID | | `-l, --label <label>` | string (repeatable) | (none) | Label to apply (repeat for multiple) | @@ -391,7 +391,7 @@ Create a new issue. ``` bmo create --title "Fix login bug" --kind bug --priority high bmo issue create --title "Fix login bug" --kind bug --priority high -bmo create -t "Add tests" -T task --parent BMO-1 --label testing +bmo create -t "Add tests" -k task --parent BMO-1 --label testing bmo create --title "Epic: v2 launch" --kind epic --json ``` @@ -476,7 +476,7 @@ Edit one or more fields on an existing issue. Only the fields you supply are upd | `-d, --description <text>` | string | (unchanged) | New description | | `-s, --status <status>` | string | (unchanged) | New status | | `-p, --priority <priority>` | string | (unchanged) | New priority | -| `-T, --kind <kind>` | string | (unchanged) | New kind | +| `-k, --kind <kind>` | string | (unchanged) | New kind | | `-a, --assignee <name>` | string | (unchanged) | New assignee | | `--parent <id>` | string | (unchanged) | New parent issue ID | diff --git a/src/cli/agent_init.rs b/src/cli/agent_init.rs index 06fabc8..786aa33 100644 --- a/src/cli/agent_init.rs +++ b/src/cli/agent_init.rs @@ -26,7 +26,7 @@ pub const CHEAT_SHEET: &str = r#"## BMO Quick Reference bmo plan --phase 1 --json # all issues in phase 1 (iterate phases 1..N) bmo board --json # full kanban overview bmo link add BMO-N blocks BMO-M --json # declare a dependency between issues - bmo file file add BMO-N <PATH> --json # add a file to an issue + bmo file add BMO-N <PATH> --json # add a file to an issue ### JSON Parsing bmo next --json | jq '.data[] | {id: .id, title: .title}' diff --git a/src/cli/issue/create.rs b/src/cli/issue/create.rs index 7addba6..4fb2f43 100644 --- a/src/cli/issue/create.rs +++ b/src/cli/issue/create.rs @@ -21,7 +21,7 @@ pub struct CreateArgs { #[arg(short, long, default_value = "medium")] pub priority: String, /// Issue kind/type - #[arg(short = 'T', long = "kind", default_value = "task")] + #[arg(short = 'k', long = "kind", default_value = "task")] pub kind: String, /// Assignee #[arg(short, long)] diff --git a/src/cli/issue/edit.rs b/src/cli/issue/edit.rs index e6d3f5f..8eabde9 100644 --- a/src/cli/issue/edit.rs +++ b/src/cli/issue/edit.rs @@ -18,7 +18,7 @@ pub struct EditArgs { pub status: Option<String>, #[arg(short, long)] pub priority: Option<String>, - #[arg(short = 'T', long = "kind")] + #[arg(short = 'k', long = "kind")] pub kind: Option<String>, #[arg(short, long)] pub assignee: Option<String>, From c3da28100736aac93d072476ce19d62c8dec9ef1 Mon Sep 17 00:00:00 2001 From: Erik Aker <eraker@gmail.com> Date: Sat, 2 May 2026 16:50:20 -0700 Subject: [PATCH 6/8] more efficiency ordering --- Cargo.lock | 55 ++++++++++++++++++++++++------------------- Cargo.toml | 12 +++++----- src/cli/agent_init.rs | 36 ++++++++++++++++------------ src/filter/mod.rs | 1 + src/model/issue.rs | 16 ++++++++++--- 5 files changed, 72 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77026ca..7bcb32a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,9 +91,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "assert_cmd" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" dependencies = [ "anstyle", "bstr", @@ -140,9 +140,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -281,9 +281,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -303,9 +303,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -1020,6 +1020,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + [[package]] name = "mime" version = "0.3.17" @@ -1028,10 +1034,11 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minijinja" -version = "2.17.1" +version = "2.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ea5ea1e90055f200af6b8e52a4a34e05e77e7fee953a9fb40c631efdc43cab1" +checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d" dependencies = [ + "memo-map", "serde", ] @@ -1262,9 +1269,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ "bitflags", "memchr", @@ -1507,9 +1514,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -1767,9 +1774,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -1782,27 +1789,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -2144,9 +2151,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" [[package]] name = "wit-bindgen" diff --git a/Cargo.toml b/Cargo.toml index 1dc3499..2b27179 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,13 +29,13 @@ path = "examples/demo.rs" ammonia = { version = "4" } anyhow = { version = "1.0.102" } async-stream = { version = "0.3" } -axum = { version = "0.8.8" } +axum = { version = "0.8.9" } chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4.6.0", features = ["derive", "env"] } +clap = { version = "4.6.1", features = ["derive", "env"] } comfy-table = { version = "7" } futures-util = { version = "0.3", default-features = false } -minijinja = { version = "2" } -pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] } +minijinja = { version = "2.19.0" } +pulldown-cmark = { version = "0.13.3", default-features = false, features = ["html"] } owo-colors = { version = "4" } rusqlite = { version = "0.38.0", features = ["bundled", "fallible_uint"] } sea-query = { version = "1.0.0-rc.1" } @@ -48,11 +48,11 @@ sea-query-rusqlite = { version = "0.8.0-rc.16", features = [ serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149" } thiserror = { version = "2.0.18" } -toml = { version = "0.9.8" } +toml = { version = "1.1.2+spec-1.1.0" } tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time"] } [dev-dependencies] -assert_cmd = { version = "2.2.0" } +assert_cmd = { version = "2.2.1" } http = { version = "1" } predicates = { version = "3" } tempfile = { version = "3.27.0" } diff --git a/src/cli/agent_init.rs b/src/cli/agent_init.rs index 786aa33..87425b2 100644 --- a/src/cli/agent_init.rs +++ b/src/cli/agent_init.rs @@ -2,10 +2,11 @@ use clap::Args; use crate::config::{Config, init_bmo_dir}; use crate::db::{Repository, open_db}; -use crate::model::{IssueFilter, Kind, Status}; +use crate::model::{IssueFilter, IssueIden, Kind, Status}; use crate::output::{BoardColumns, OutputMode, make_printer}; use crate::planner::dag::{Dag, find_ready}; use crate::planner::topo::topological_levels; +use sea_query::Order; #[derive(Args)] pub struct AgentInitArgs {} @@ -57,22 +58,24 @@ pub fn run(_args: &AgentInitArgs, json: bool) -> anyhow::Result<()> { // 3. board — active issues (non-done) + most recent 10 done let active_issues = repo.list_issues(IssueFilter::default())?; - let mut done_issues = repo.list_issues(IssueFilter { + let done_issues = repo.list_issues(IssueFilter { status: Some(vec![Status::Done]), + order_by: Some(vec![(IssueIden::Id, Order::Desc)]), + limit: Some(10), ..Default::default() })?; - done_issues.sort_by_key(|b| std::cmp::Reverse(b.id)); - done_issues.truncate(10); // Most recent epic across all statuses let recent_epic = repo .list_issues(IssueFilter { include_done: true, kind: Some(vec![Kind::Epic]), + order_by: Some(vec![(IssueIden::Id, Order::Desc)]), + limit: Some(1), ..Default::default() })? .into_iter() - .max_by_key(|issue| issue.id); + .next(); let board = BoardColumns { backlog: active_issues @@ -202,20 +205,23 @@ fn run_with_dir_inner(bmo_dir: &std::path::Path, json: bool) -> anyhow::Result<( let config = Config::load(bmo_dir)?; let active_issues = repo.list_issues(IssueFilter::default())?; - let mut done_issues = repo.list_issues(IssueFilter { + let done_issues = repo.list_issues(IssueFilter { status: Some(vec![Status::Done]), + order_by: Some(vec![(IssueIden::Id, Order::Desc)]), + limit: Some(10), ..Default::default() })?; - done_issues.sort_by_key(|b| std::cmp::Reverse(b.id)); - done_issues.truncate(10); - let mut all_epics = repo.list_issues(IssueFilter { - include_done: true, - kind: Some(vec![Kind::Epic]), - ..Default::default() - })?; - all_epics.sort_by_key(|b| std::cmp::Reverse(b.id)); - let recent_epic = all_epics.into_iter().next(); + let recent_epic = repo + .list_issues(IssueFilter { + include_done: true, + kind: Some(vec![Kind::Epic]), + order_by: Some(vec![(IssueIden::Id, Order::Desc)]), + limit: Some(1), + ..Default::default() + })? + .into_iter() + .next(); let board = BoardColumns { backlog: active_issues diff --git a/src/filter/mod.rs b/src/filter/mod.rs index 8f3afd3..154e9cd 100644 --- a/src/filter/mod.rs +++ b/src/filter/mod.rs @@ -85,6 +85,7 @@ impl FilterBuilder { } else { Some(self.offset) }, + order_by: None, include_done: self.include_done, findall: self.findall, }) diff --git a/src/model/issue.rs b/src/model/issue.rs index b7c6a68..21cdb31 100644 --- a/src/model/issue.rs +++ b/src/model/issue.rs @@ -248,6 +248,9 @@ pub struct IssueFilter { pub search: Option<String>, pub limit: Option<usize>, pub offset: Option<usize>, + /// Custom ordering to apply instead of the default (priority DESC, id ASC). + /// When `Some`, replaces the default ORDER BY entirely. + pub order_by: Option<Vec<(IssueIden, Order)>>, // By default filters above do not include `done` status` pub include_done: bool, // short-circuit everything above and select all issues. @@ -356,9 +359,16 @@ impl IssueFilter { } // ORDER BY / LIMIT / OFFSET always applied, including when findall = true. - query = query - .order_by(IssueIden::Priority, Order::Desc) - .order_by(IssueIden::Id, Order::Asc); + // When `order_by` is explicitly set, use it instead of the default ordering. + if let Some(ref custom_order) = self.order_by { + for (col, dir) in custom_order { + query = query.order_by(*col, dir.clone()); + } + } else { + query = query + .order_by(IssueIden::Priority, Order::Desc) + .order_by(IssueIden::Id, Order::Asc); + } query.apply_if(self.limit, |q, v| { q.limit(v as u64); }); From c782bd95dcacd0dc3da5afc42b24e06e0459cde7 Mon Sep 17 00:00:00 2001 From: Erik Aker <eraker@gmail.com> Date: Sun, 3 May 2026 12:00:14 -0700 Subject: [PATCH 7/8] fix link printers --- src/cli/plan.rs | 10 +++++++++- src/output/human.rs | 6 +++--- src/web/templates/issue.html | 7 +++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/cli/plan.rs b/src/cli/plan.rs index 881d038..2da2ee6 100644 --- a/src/cli/plan.rs +++ b/src/cli/plan.rs @@ -18,6 +18,10 @@ pub struct PlanArgs { /// envelope; in human mode prints a header plus a formatted list. #[arg(long)] pub phase: Option<u32>, + + /// Ignore link relations when building the plan; ordering is driven solely by file conflicts. + #[arg(long)] + pub no_links: bool, } pub fn run(args: &PlanArgs, json: bool) -> anyhow::Result<()> { @@ -36,7 +40,11 @@ pub fn run(args: &PlanArgs, json: bool) -> anyhow::Result<()> { } let all_issues = repo.list_issues(IssueFilter::default())?; - let all_relations = repo.list_all_relations()?; + let all_relations = if args.no_links { + vec![] + } else { + repo.list_all_relations()? + }; let dag = Dag::build(&all_issues, &all_relations); diff --git a/src/output/human.rs b/src/output/human.rs index 7f8477e..51c21db 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -255,12 +255,12 @@ impl Printer for HumanPrinter { for rel in &detail.relations { let direction = match rel.kind { RelationKind::Blocks => format!("→ blocks BMO-{}", rel.to_id), - RelationKind::BlockedBy => format!("← blocked by BMO-{}", rel.from_id), + RelationKind::BlockedBy => format!("← blocked by BMO-{}", rel.to_id), RelationKind::DependsOn => format!("→ depends on BMO-{}", rel.to_id), - RelationKind::DependencyOf => format!("← dependency of BMO-{}", rel.from_id), + RelationKind::DependencyOf => format!("← dependency of BMO-{}", rel.to_id), RelationKind::RelatesTo => format!("↔ relates to BMO-{}", rel.to_id), RelationKind::Duplicates => format!("→ duplicates BMO-{}", rel.to_id), - RelationKind::DuplicateOf => format!("← duplicate of BMO-{}", rel.from_id), + RelationKind::DuplicateOf => format!("← duplicate of BMO-{}", rel.to_id), }; println!(" {direction}"); } diff --git a/src/web/templates/issue.html b/src/web/templates/issue.html index f175ce9..c835cad 100644 --- a/src/web/templates/issue.html +++ b/src/web/templates/issue.html @@ -13,6 +13,13 @@ <h1>BMO-{{ issue.id }}: {{ issue.title }}</h1> <div class="description">{{ issue.description | markdown }}</div> {% endif %} + {% if issue.files %} + <h2>Files</h2> + <ul class="file-list"> + {% for f in issue.files %}<li><code>{{ f }}</code></li>{% endfor %} + </ul> + {% endif %} + {% if comments %} <h2>Comments</h2> {% for c in comments %} From 8e66742e9cb5ee225a6d7931e5d0a9296fa7e1de Mon Sep 17 00:00:00 2001 From: Erik Aker <eraker@gmail.com> Date: Sun, 3 May 2026 16:35:02 -0700 Subject: [PATCH 8/8] Making sure relates and files appear in the right spots --- Cargo.lock | 8 ++++---- Cargo.toml | 4 ++-- src/output/human.rs | 6 ++++++ src/web/handlers.rs | 7 ++++++- src/web/templates/issue.html | 29 ++++++++++++++++++++++++++--- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7bcb32a..f900ac8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1415,9 +1415,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sea-query" -version = "1.0.0-rc.32" +version = "1.0.0-rc.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e504013549f817509224b39f2381da6b6e18e48cb7144a24ea13b6f22089e76c" +checksum = "b04cdb0135c16e829504e93fbe7880513578d56f07aaea152283526590111828" dependencies = [ "chrono", "inherent", @@ -1444,9 +1444,9 @@ dependencies = [ [[package]] name = "sea-query-rusqlite" -version = "0.8.0-rc.16" +version = "0.8.0-rc.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c532ce4a6a25a31fdcdeefd169f067299e79bd43fb4a0da2a4699ee7a526b71" +checksum = "05c805badea80303bd65892015f666c023a17eac6f888f2f32d02a325ee542e1" dependencies = [ "rusqlite", "sea-query", diff --git a/Cargo.toml b/Cargo.toml index 2b27179..ec6f325 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,8 @@ minijinja = { version = "2.19.0" } pulldown-cmark = { version = "0.13.3", default-features = false, features = ["html"] } owo-colors = { version = "4" } rusqlite = { version = "0.38.0", features = ["bundled", "fallible_uint"] } -sea-query = { version = "1.0.0-rc.1" } -sea-query-rusqlite = { version = "0.8.0-rc.16", features = [ +sea-query = { version = "1.0.0-rc.33" } +sea-query-rusqlite = { version = "0.8.0-rc.17", features = [ "with-chrono", "with-json", "with-uuid", diff --git a/src/output/human.rs b/src/output/human.rs index 51c21db..fbbebda 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -239,6 +239,12 @@ impl Printer for HumanPrinter { issue.description.replace('\n', "\n ") ); } + if !issue.files.is_empty() { + println!("\n Files:"); + for f in &issue.files { + println!(" {f}"); + } + } if !detail.sub_issues.is_empty() { println!("\n Sub-issues ({}):", detail.sub_issues.len()); for sub in &detail.sub_issues { diff --git a/src/web/handlers.rs b/src/web/handlers.rs index 6a5aba2..7b40557 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -140,13 +140,18 @@ pub async fn issue_detail_page( let issue = repo.get_issue(id)?; if let Some(issue) = issue { let comments = repo.list_comments(id)?; + let relations = repo.list_relations(id)?; let issue_json = serde_json::to_value(&issue)?; let comments_json: Vec<serde_json::Value> = comments .iter() .map(|c| serde_json::to_value(c).map_err(|e| anyhow::anyhow!(e))) .collect::<Result<Vec<_>, _>>()?; + let relations_json: Vec<serde_json::Value> = relations + .iter() + .map(|r| serde_json::to_value(r).map_err(|e| anyhow::anyhow!(e))) + .collect::<Result<Vec<_>, _>>()?; let tmpl = state.env.get_template("issue.html")?; - let html = tmpl.render(context!(issue => issue_json, comments => comments_json))?; + let html = tmpl.render(context!(issue => issue_json, comments => comments_json, relations => relations_json))?; anyhow::Ok(Some(html)) } else { anyhow::Ok(None) diff --git a/src/web/templates/issue.html b/src/web/templates/issue.html index c835cad..634d442 100644 --- a/src/web/templates/issue.html +++ b/src/web/templates/issue.html @@ -15,9 +15,32 @@ <h1>BMO-{{ issue.id }}: {{ issue.title }}</h1> {% if issue.files %} <h2>Files</h2> - <ul class="file-list"> - {% for f in issue.files %}<li><code>{{ f }}</code></li>{% endfor %} - </ul> + <div class="description"> + <ul class="file-list"> + {% for f in issue.files %}<li><code>{{ f }}</code></li>{% endfor %} + </ul> + </div> + {% endif %} + + {% if relations %} + <h2>Relations</h2> + <div class="description"> + <ul class="relation-list"> + {% for r in relations %} + <li> + {% if r.kind == "blocks" %}→ blocks <a href="/issues/{{ r.to_id }}">BMO-{{ r.to_id }}</a> + {% elif r.kind == "blocked-by" %}← blocked by <a href="/issues/{{ r.to_id }}">BMO-{{ r.to_id }}</a> + {% elif r.kind == "depends-on" %}→ depends on <a href="/issues/{{ r.to_id }}">BMO-{{ r.to_id }}</a> + {% elif r.kind == "dependency-of" %}← dependency of <a href="/issues/{{ r.to_id }}">BMO-{{ r.to_id }}</a> + {% elif r.kind == "relates-to" %}↔ relates to <a href="/issues/{{ r.to_id }}">BMO-{{ r.to_id }}</a> + {% elif r.kind == "duplicates" %}→ duplicates <a href="/issues/{{ r.to_id }}">BMO-{{ r.to_id }}</a> + {% elif r.kind == "duplicate-of" %}← duplicate of <a href="/issues/{{ r.to_id }}">BMO-{{ r.to_id }}</a> + {% else %}{{ r.kind }} <a href="/issues/{{ r.to_id }}">BMO-{{ r.to_id }}</a> + {% endif %} + </li> + {% endfor %} + </ul> + </div> {% endif %} {% if comments %}