diff --git a/README.md b/README.md index e0b4162..143d637 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | OIDC hub auth — bearer-token verification + owner gate | ✅ done | | OIDC client — device-code login, keyring token cache | ✅ done | | `heph.nvim` (primary surface) — RPC client, buffer-backed editing, wiki-link follow, journal (slice 11a) | ✅ done | -| `heph.nvim` — task/agenda views, promotion, CI runner (slices 11b–11c) | ⏳ next | +| `heph.nvim` — Tactical/Organizational task views, capture, attention, done/drop, log (slice 11b) | ✅ done | +| `heph.nvim` — context-item promotion + Dagger CI runner (slice 11c) | ⏳ next | ## Architecture diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 822da3b..7ed2af2 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -241,7 +241,7 @@ impl Store for LocalStore { scope: Option<&str>, attention: Option, include_blue: bool, - ) -> Result> { + ) -> Result> { tasks::list(&self.conn, &self.owner_id, scope, attention, include_blue) } diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 197c1c0..4a5164f 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -283,35 +283,37 @@ pub(super) fn next( } /// Enumerate outstanding committed tasks for the Organizational view (the whole -/// set incl. backlog, tech-spec §6). Optional `scope` (project) and `attention` -/// filters; `include_blue` keeps on-deck items (default true for `list`). +/// set incl. backlog, tech-spec §6) as **titled rows** ([`RankedTask`] shape — +/// the same the plugin renders for `next`, so the survey view needs no N+1 +/// `node.get`). Optional `scope` (project) and `attention` filters; +/// `include_blue` keeps on-deck items (default true for `list`). pub(super) fn list( conn: &Connection, owner: &str, scope: Option<&str>, attention: Option, include_blue: bool, -) -> Result> { +) -> Result> { let sql = " - SELECT t.node_id, t.attention, t.do_date, t.late_on, t.state, t.recurrence, + SELECT n.id, n.title, n.created_at, n.tombstoned, + t.attention, t.do_date, t.late_on, t.state, (SELECT dst_id FROM links - WHERE src_id = t.node_id AND type = 'in-project' AND tombstoned = 0 - ORDER BY created_at, id LIMIT 1) AS project_id + WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS project_id, + (SELECT dst_id FROM links + WHERE src_id = n.id AND type = 'canonical-context' AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1) AS ctx_id FROM tasks t JOIN nodes n ON n.id = t.node_id WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding' ORDER BY n.created_at, n.id"; let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([owner], |row| { - let task = from_row(row)?; - let project: Option = row.get("project_id")?; - Ok((task, project)) - })?; + let rows = stmt.query_map([owner], ranked_from_row)?; let mut out = Vec::new(); for row in rows { - let (task, project) = row?; + let task = row?; if let Some(s) = scope { - if project.as_deref() != Some(s) { + if task.project_id.as_deref() != Some(s) { continue; } } @@ -376,31 +378,36 @@ fn load_candidates(conn: &Connection, owner: &str) -> Result> { FROM tasks t JOIN nodes n ON n.id = t.node_id WHERE n.owner_id = ?1 AND n.tombstoned = 0"; let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([owner], |row| { - let attention = match row.get::<_, Option>("attention")? { - Some(s) => Some( - Attention::parse(&s) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, - ), - None => None, - }; - Ok(RankedTask { - node_id: row.get("id")?, - title: row.get("title")?, - attention, - do_date: row.get("do_date")?, - late_on: row.get("late_on")?, - state: TaskState::parse(&row.get::<_, String>("state")?) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, - tombstoned: row.get::<_, i64>("tombstoned")? != 0, - project_id: row.get("project_id")?, - canonical_context_id: row.get("ctx_id")?, - created_at: row.get("created_at")?, - }) - })?; + let rows = stmt.query_map([owner], ranked_from_row)?; Ok(rows.collect::>>()?) } +/// Map a row selected with the `id, title, created_at, tombstoned, attention, +/// do_date, late_on, state, project_id, ctx_id` shape into a [`RankedTask`]. +/// Shared by `next`'s candidate load and the enriched `list`. +fn ranked_from_row(row: &Row) -> rusqlite::Result { + let attention = match row.get::<_, Option>("attention")? { + Some(s) => Some( + Attention::parse(&s) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + ), + None => None, + }; + Ok(RankedTask { + node_id: row.get("id")?, + title: row.get("title")?, + attention, + do_date: row.get("do_date")?, + late_on: row.get("late_on")?, + state: TaskState::parse(&row.get::<_, String>("state")?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?, + tombstoned: row.get::<_, i64>("tombstoned")? != 0, + project_id: row.get("project_id")?, + canonical_context_id: row.get("ctx_id")?, + created_at: row.get("created_at")?, + }) +} + /// Set a task's attention-state. pub(super) fn set_attention( conn: &Connection, diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index a9c50f7..a5137f9 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -77,13 +77,15 @@ pub trait Store { fn next(&self, scope: Option<&str>, limit: usize) -> Result>; /// Enumerate outstanding committed tasks for the Organizational view — the - /// whole set incl. backlog (tech-spec §6). `include_blue` keeps on-deck. + /// whole set incl. backlog (tech-spec §6), as **titled** [`RankedTask`] rows + /// (the same shape `next` returns, so the survey view needs no N+1 + /// `get_node`). `include_blue` keeps on-deck. fn list( &self, scope: Option<&str>, attention: Option, include_blue: bool, - ) -> Result>; + ) -> Result>; /// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7). fn health(&self) -> Result; diff --git a/crates/heph-core/tests/query_surface.rs b/crates/heph-core/tests/query_surface.rs index 3ebe0d6..a09493a 100644 --- a/crates/heph-core/tests/query_surface.rs +++ b/crates/heph-core/tests/query_surface.rs @@ -47,6 +47,20 @@ fn list_can_exclude_blue_and_filter_by_attention() { ); } +#[test] +fn list_rows_carry_title_and_canonical_context() { + let mut s = store(); + let id = task(&mut s, "Buy milk", Attention::Orange); + + // The Organizational view needs titles + the one-keystroke context jump + // without an N+1 node.get (tech-spec §6, §8). + let rows = s.list(None, None, true).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].node_id, id); + assert_eq!(rows[0].title, "Buy milk"); + assert!(rows[0].canonical_context_id.is_some()); +} + #[test] fn list_scopes_to_a_project() { let mut s = store(); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index d777625..3d3cdae 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -169,7 +169,7 @@ impl Store for RemoteStore { scope: Option<&str>, attention: Option, include_blue: bool, - ) -> Result> { + ) -> Result> { self.call_as( "list", json!({ "scope": scope, "attention": attention, "include_blue": include_blue }), diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index a1a770d..919c122 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -15,3 +15,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Client authentication (§13, slice 10b): `heph auth login --hub-url --issuer --client-id ` runs the OAuth 2.0 device-code flow and caches the token in the OS keyring; spokes and `client` mode attach it to hub requests, refreshing on expiry (`--oidc-issuer`/`--oidc-client-id`). Offline-tested against a mock OAuth server and a full spoke-to-authenticated-hub loop. (Auth/proxy HTTP uses the runtime-free `ureq`, since `reqwest::blocking` is unsafe inside the async daemon.) - CI runs the Rust suite (fmt/clippy/test) via the project build hook. - `heph.nvim` slice 11a (§8) — the primary surface begins: a Neovim plugin that is a thin client of the local `hephd` over its unix socket. A `vim.uv` JSON-RPC client (blocking `call` via `vim.wait`, id-demuxed, partial-line buffered, JSON `null`→Lua `nil`); buffer-backed nodes (`heph://node/` with `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update`, whole-buffer body round-tripping exactly through the CRDT); `[[wiki-link]]` follow on `` via a new exact `node.resolve {title}` RPC (alias-then-title, the same mapping that materializes `wiki` links — unresolved links allowed); the daily journal (`:Heph today`); and the `:Heph` command surface. Headless e2e (§9) drives the plugin against a real daemon over a temp socket with a self-contained busted-style runner (no external plugins, no network): journal round-trip, follow-link, and link-two-docs/backlink. +- `heph.nvim` slice 11b (§8) — task views: `list` is enriched to return titled rows (the same shape as `next`, with the canonical-context id) so the Organizational survey needs no per-row `node.get`. The plugin gains the Tactical **`:Heph next`** and Organizational **`:Heph list`** views (`` opens a task's canonical-context doc), task **capture**, **set-attention**, **done/drop**, **skip**, and per-task **`log`** append — each resolving "the current task" from the buffer (a task node, or a context doc via its `canonical-context` backlink). A `vim.ui.select` picker (Telescope auto-upgrade when installed) backs `:Heph search`/`capture`/`attention`. Headless e2e adds the capture→next→context→checklist→done workflow and the recurring fresh-checklist workflow (completing a recurring task rolls it forward and the next occurrence presents an all-unchecked checklist). diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 3295457..a669055 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -40,17 +40,28 @@ non-tombstoned alias-then-title match — the same mapping the store uses to materialize `wiki` links, so "follow link under cursor" jumps to the *same* node the stored link points at. -## Commands (as of slice 11a) +## Commands (as of slice 11b) | Command | Action | |---|---| -| `:Heph today` | Open today's journal | -| `:Heph journal ` | Open a dated journal | +| `:Heph today` / `:Heph journal ` | Open today's / a dated journal | | `:Heph follow` (also `` in a node buffer) | Follow the `[[link]]` under the cursor | | `:Heph open ` | Open a node buffer by id | +| `:Heph search ` | Full-text search; pick a result to open | +| `:Heph next [scope]` | Tactical "what is next?" view (`` opens a task's context) | +| `:Heph list [attention]` | Organizational survey of the outstanding set | +| `:Heph capture ` | Capture a committed task (pick attention) | +| `:Heph attention [color]` | Set the current task's attention | +| `:Heph done` / `:Heph drop` / `:Heph skip` | State change on the current task | +| `:Heph log <text>` | Append a breadcrumb to the current task's log | -Task/agenda views (`:Heph next`/`list`/`capture`, set-attention, done/drop), -the per-task log, and context-item **promotion** arrive in slices 11b/11c. +"Current task" is resolved from the buffer: a `task` node, or a canonical-context +doc whose owning task is followed via its `canonical-context` backlink. The +`next`/`list` views render the titled rows the daemon returns (`list` enriched to +carry titles + the context id, so no N+1 `node.get`). Pickers use built-in +`vim.ui.select`, auto-upgrading to Telescope when installed. + +Context-item **promotion** (`:Heph promote`) and the CI runner arrive in slice 11c. ## Testing (tech-spec §9) diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index a5dc5f6..1009e34 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -327,7 +327,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi ## 14. Implementation status (Phase 1 tracker) -> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **114 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`make -C heph.nvim test`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slice 11a). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **115 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`mise run test-nvim`, 6 specs), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11b). **Done** @@ -346,16 +346,16 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**. - ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup). - ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c). +- ✅ **`heph.nvim` slice 11b (§8) — task views:** **`list` enriched** to titled [`RankedTask`] rows (title + `canonical_context_id`, shared `ranked_from_row` with `next`) so the Organizational view needs no N+1 `node.get`. Plugin: **Tactical `next`** + **Organizational `list`** views (rendered scratch buffers, `<CR>` opens a row's canonical-context doc — the node autocmd narrowed to `heph://node/*` so view buffers don't trip it); **task capture**, **set-attention**, **done/drop**, **skip**, **per-task `log` append** — all resolving "the current task" from the buffer (a `task` node, or a context doc via its `canonical-context` backlink); **`vim.ui.select` picker** (`picker.lua`) with Telescope auto-upgrade; `:Heph next/list/capture/attention/done/drop/skip/log/search` subcommands. e2e specs: **capture→next→open context→add/check checklist→done**, and **recurring fresh-checklist** (complete rolls forward in place; the next occurrence is all-unchecked — the §4.4 hard requirement). **Not yet done (resume order)** -> The Rust backend is feature-complete; `heph.nvim` is being built in checkpointed sub-slices (11a done). The rest are non-blocking polish + an end-of-v1 sweep (§11). +> The Rust backend is feature-complete; `heph.nvim` is being built in checkpointed sub-slices (11a–11b done). The rest are non-blocking polish + an end-of-v1 sweep (§11). -1. ⏳ **`heph.nvim` slice 11b (§8) — task views:** enrich `list` to titled rows; Tactical `next` + Organizational `list` views; task capture, set-attention, mark done/dropped; per-task log quick-append; `vim.ui.select` pickers (Telescope auto-upgrade when present). e2e: capture→next→context→checklist→done, and the recurring fresh-checklist workflow. -2. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; **CI via Dagger** — a `test` function in `.dagger/` (mirroring `build_docs`) bakes a pinned `neovim` + Rust toolchain into a container and runs the e2e suite (the self-contained busted runner needs **no** plenary). Dev stays native: `mise run test-nvim` against system nvim/rustc; Dagger is the CI-only provisioner. The Forgejo workflow shrinks to `dagger call test`. -3. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). -4. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. -5. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. +1. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; **CI via Dagger** — a `test` function in `.dagger/` (mirroring `build_docs`) bakes a pinned `neovim` + Rust toolchain into a container and runs the e2e suite (the self-contained busted runner needs **no** plenary). Dev stays native: `mise run test-nvim` against system nvim/rustc; Dagger is the CI-only provisioner. The Forgejo workflow shrinks to `dagger call test`. +2. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9). +3. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. +4. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite. ## Related diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index 8953d63..b16133a 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -4,8 +4,11 @@ local M = {} +local ATTENTIONS = { "white", "orange", "red", "blue" } + --- subcommand -> handler(args: string[]) M.subs = { + -- knowledge base today = function() require("heph.journal").open() end, @@ -20,6 +23,66 @@ M.subs = { require("heph.node").open(args[1]) end end, + search = function(args) + local query = table.concat(args, " ") + if #query == 0 then + require("heph.util").notify("usage: :Heph search <query>", vim.log.levels.WARN) + return + end + local nodes = require("heph.rpc").call("search", { query = query }) + require("heph.picker").select(nodes, { + prompt = "heph search: " .. query, + format = function(n) + return string.format("[%s] %s", n.kind, n.title) + end, + }, function(choice) + if choice then + require("heph.node").open(choice.id) + end + end) + end, + + -- tasks + next = function(args) + require("heph.view").next({ scope = args[1] }) + end, + list = function(args) + require("heph.view").list({ attention = args[1] }) + end, + capture = function(args) + local title = table.concat(args, " ") + if #title == 0 then + require("heph.util").notify("usage: :Heph capture <title>", vim.log.levels.WARN) + return + end + require("heph.picker").select(ATTENTIONS, { prompt = "attention for: " .. title }, function(attention) + require("heph.task").capture(title, { attention = attention }) + require("heph.util").notify("captured: " .. title) + end) + end, + attention = function(args) + if args[1] then + require("heph.task").set_attention_current(args[1]) + else + require("heph.picker").select(ATTENTIONS, { prompt = "attention" }, function(choice) + if choice then + require("heph.task").set_attention_current(choice) + end + end) + end + end, + done = function() + require("heph.task").set_state_current("done") + end, + drop = function() + require("heph.task").set_state_current("dropped") + end, + skip = function() + require("heph.task").skip_current() + end, + log = function(args) + require("heph.task").log_append_current(table.concat(args, " ")) + end, } --- `:Heph` entry point. diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua index 9453e1a..ddc1dd4 100644 --- a/heph.nvim/lua/heph/config.lua +++ b/heph.nvim/lua/heph/config.lua @@ -33,7 +33,15 @@ function M.apply_keymaps(opts) map("n", "<leader>hj", function() require("heph.journal").open() end, { desc = "heph: today's journal" }) - -- Task/agenda maps are added with their views in slice 11b. + map("n", "<leader>hn", function() + require("heph.view").next() + end, { desc = "heph: what is next (Tactical)" }) + map("n", "<leader>hl", function() + require("heph.view").list() + end, { desc = "heph: task list (Organizational)" }) + map("n", "<leader>hd", function() + require("heph.task").set_state_current("done") + end, { desc = "heph: mark current task done" }) end return M diff --git a/heph.nvim/lua/heph/picker.lua b/heph.nvim/lua/heph/picker.lua new file mode 100644 index 0000000..b7726db --- /dev/null +++ b/heph.nvim/lua/heph/picker.lua @@ -0,0 +1,56 @@ +--- A single selection primitive. Uses built-in `vim.ui.select` so headless e2e +--- needs no plugins; auto-upgrades to Telescope when it is installed and not +--- explicitly disabled. Tests set `vim.g.heph_force_ui_select` and stub +--- `vim.ui.select`, so a picker never blocks in `--headless`. + +local M = {} + +local function telescope_available() + return pcall(require, "telescope") +end + +--- Select one of `items`. `opts.prompt`, `opts.format(item)->string`. +--- `on_choice(item|nil, index|nil)` — nil when cancelled. +function M.select(items, opts, on_choice) + opts = opts or {} + if not vim.g.heph_force_ui_select and telescope_available() then + -- Telescope path: a thin wrapper so fuzzy UX is available when present. + -- (The dropdown is intentionally minimal; richer pickers can come later.) + local pickers = require("telescope.pickers") + local finders = require("telescope.finders") + local conf = require("telescope.config").values + local actions = require("telescope.actions") + local action_state = require("telescope.actions.state") + pickers + .new({}, { + prompt_title = opts.prompt or "heph", + finder = finders.new_table({ + results = items, + entry_maker = function(item) + local display = opts.format and opts.format(item) or tostring(item) + return { value = item, display = display, ordinal = display } + end, + }), + sorter = conf.generic_sorter({}), + attach_mappings = function(bufnr) + actions.select_default:replace(function() + actions.close(bufnr) + local sel = action_state.get_selected_entry() + on_choice(sel and sel.value or nil) + end) + return true + end, + }) + :find() + return + end + + vim.ui.select(items, { + prompt = opts.prompt, + format_item = opts.format, + }, function(choice, idx) + on_choice(choice, idx) + end) +end + +return M diff --git a/heph.nvim/lua/heph/task.lua b/heph.nvim/lua/heph/task.lua new file mode 100644 index 0000000..87be1c5 --- /dev/null +++ b/heph.nvim/lua/heph/task.lua @@ -0,0 +1,90 @@ +--- Task actions (tech-spec §8): capture, attention, state, skip, log. Actions +--- that operate on "the current task" resolve it from the buffer — either the +--- buffer is a `task` node, or it is a task's canonical-context doc, in which +--- case we follow the `canonical-context` backlink to its owning task. + +local rpc = require("heph.rpc") +local util = require("heph.util") + +local M = {} + +--- Capture a committed task. `opts`: attention, do_date, late_on, recurrence, +--- project. Returns the created task. +function M.capture(title, opts) + opts = opts or {} + return rpc.call("task.create", { + title = title, + attention = opts.attention, + do_date = opts.do_date, + late_on = opts.late_on, + recurrence = opts.recurrence, + project_id = opts.project, + }) +end + +--- The committed task id associated with the current buffer, or nil. +function M.current_task_id() + local buf = vim.api.nvim_get_current_buf() + local id = vim.b[buf].heph_node_id + if not id then + return nil + end + if vim.b[buf].heph_node_kind == "task" then + return id + end + -- A canonical-context doc: its owning task is the src of the + -- canonical-context link pointing here. + for _, l in ipairs(rpc.call("links.backlinks", { id = id })) do + if l.link_type == "canonical-context" then + return l.src_id + end + end + return nil +end + +local function with_task(action, what) + local id = M.current_task_id() + if not id then + util.notify("no task associated with this buffer", vim.log.levels.WARN) + return nil + end + action(id) + if what then + util.notify(what) + end + return id +end + +--- Mark the current task done/dropped (recurring tasks roll forward on done). +function M.set_state_current(state) + return with_task(function(id) + rpc.call("task.set_state", { id = id, state = state }) + end, "task " .. state) +end + +--- Set the current task's attention. +function M.set_attention_current(attention) + return with_task(function(id) + rpc.call("task.set_attention", { id = id, attention = attention }) + end, "attention → " .. attention) +end + +--- Skip the current recurring task's occurrence (advance without logging). +function M.skip_current() + return with_task(function(id) + rpc.call("task.skip", { id = id }) + end, "occurrence skipped") +end + +--- Append a line to the current task's log (the resumption breadcrumb). +function M.log_append_current(text) + if not text or #text == 0 then + util.notify("nothing to log", vim.log.levels.WARN) + return nil + end + return with_task(function(id) + rpc.call("log.append", { task_id = id, text = text }) + end, "logged") +end + +return M diff --git a/heph.nvim/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua new file mode 100644 index 0000000..82e6792 --- /dev/null +++ b/heph.nvim/lua/heph/view.lua @@ -0,0 +1,89 @@ +--- Task list views (tech-spec §8): Tactical `next` (the "what is next?" ranking) +--- and Organizational `list` (the whole outstanding set). Both render the same +--- titled rows the daemon returns into a scratch buffer; `<CR>` opens the task +--- under the cursor's canonical-context doc (the one-keystroke jump). + +local rpc = require("heph.rpc") + +local M = {} + +-- buf -> { tasks = <RankedTask[]> }; line N maps to tasks[N]. +M._views = {} + +local function row(t) + local tag = t.attention and ("[" .. t.attention .. "]") or "[ ]" + return string.format("%s %s", tag, t.title) +end + +-- Find or create the named scratch buffer and fill it with task rows. +local function render(name, tasks) + local buf + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_get_name(b) == name then + buf = b + break + end + end + if not buf then + buf = vim.api.nvim_create_buf(false, true) -- unlisted scratch + vim.api.nvim_buf_set_name(buf, name) + end + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "hide" + vim.bo[buf].swapfile = false + + local lines = {} + for _, t in ipairs(tasks) do + lines[#lines + 1] = row(t) + end + if #lines == 0 then + lines = { "(nothing here)" } + end + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false + + M._views[buf] = { tasks = tasks } + vim.keymap.set("n", "<CR>", function() + M.open_under_cursor(buf) + end, { buffer = buf, desc = "heph: open task context" }) + vim.api.nvim_set_current_buf(buf) + return buf +end + +--- Open the canonical-context doc of the task on the cursor line. +function M.open_under_cursor(buf) + buf = buf or vim.api.nvim_get_current_buf() + local view = M._views[buf] + if not view then + return + end + local lnum = vim.api.nvim_win_get_cursor(0)[1] + local t = view.tasks[lnum] + if not t then + return + end + require("heph.node").open(t.canonical_context_id or t.node_id) +end + +--- Tactical "what is next?" — render the ranking, return the rows. +function M.next(opts) + opts = opts or {} + local tasks = rpc.call("next", { scope = opts.scope, limit = opts.limit or 5 }) + render("heph://next", tasks) + return tasks +end + +--- Organizational survey — render the outstanding set, return the rows. +function M.list(opts) + opts = opts or {} + local tasks = rpc.call("list", { + scope = opts.scope, + attention = opts.attention, + include_blue = opts.include_blue ~= false, + }) + render("heph://list", tasks) + return tasks +end + +return M diff --git a/heph.nvim/plugin/heph.lua b/heph.nvim/plugin/heph.lua index 40502e2..1e708ab 100644 --- a/heph.nvim/plugin/heph.lua +++ b/heph.nvim/plugin/heph.lua @@ -11,7 +11,7 @@ local grp = vim.api.nvim_create_augroup("heph", { clear = true }) -- `heph://node/<id>` buffers load and save through the daemon (tech-spec §8). vim.api.nvim_create_autocmd("BufReadCmd", { group = grp, - pattern = "heph://*", + pattern = "heph://node/*", callback = function(ev) local ok, err = pcall(require("heph.node").read, ev.buf, ev.match) if not ok then @@ -22,7 +22,7 @@ vim.api.nvim_create_autocmd("BufReadCmd", { vim.api.nvim_create_autocmd("BufWriteCmd", { group = grp, - pattern = "heph://*", + pattern = "heph://node/*", callback = function(ev) local ok, err = pcall(require("heph.node").write, ev.buf, ev.match) if not ok then diff --git a/heph.nvim/tests/e2e/capture_spec.lua b/heph.nvim/tests/e2e/capture_spec.lua new file mode 100644 index 0000000..2bba877 --- /dev/null +++ b/heph.nvim/tests/e2e/capture_spec.lua @@ -0,0 +1,56 @@ +-- Workflow (a): capture a task -> it appears in :Heph next -> open its canonical +-- context -> add a checklist item -> check it -> mark the task done. + +local h = require("e2e.helpers") + +describe("task capture to done", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("captures, surfaces in next, edits the context checklist, and marks done", function() + local task = require("heph.task").capture("Fix roof", { attention = "orange" }) + assert.is_truthy(task.node_id) + + -- Surfaces in the Tactical view. + local ranked = require("heph.view").next() + local viewbuf = vim.api.nvim_get_current_buf() + local present = false + for _, l in ipairs(vim.api.nvim_buf_get_lines(viewbuf, 0, -1, false)) do + if l:find("Fix roof", 1, true) then + present = true + end + end + assert.is_true(present, "task missing from :Heph next") + assert.are.equal(task.node_id, ranked[1].node_id) + assert.is_truthy(ranked[1].canonical_context_id) + + -- Jump to its canonical context from the view (the one-keystroke jump). + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + require("heph.view").open_under_cursor() + local ctxbuf = vim.api.nvim_get_current_buf() + assert.are.equal( + "heph://node/" .. ranked[1].canonical_context_id, + vim.api.nvim_buf_get_name(ctxbuf) + ) + + -- Add a checklist item and save. + vim.api.nvim_buf_set_lines(ctxbuf, 0, -1, false, { "- [ ] buy shingles" }) + h.save(ctxbuf) + local stored = ctx.q:call("node.get", { id = ranked[1].canonical_context_id }) + assert.are.equal("- [ ] buy shingles", stored.body) + + -- Check it off and save. + vim.api.nvim_buf_set_lines(ctxbuf, 0, -1, false, { "- [x] buy shingles" }) + h.save(ctxbuf) + + -- Mark the task done from its context buffer (resolves the owning task). + local done_id = require("heph.task").set_state_current("done") + assert.are.equal(task.node_id, done_id) + assert.are.equal("done", ctx.q:call("task.get", { id = task.node_id }).state) + end) +end) diff --git a/heph.nvim/tests/e2e/recurring_spec.lua b/heph.nvim/tests/e2e/recurring_spec.lua new file mode 100644 index 0000000..eb9a069 --- /dev/null +++ b/heph.nvim/tests/e2e/recurring_spec.lua @@ -0,0 +1,54 @@ +-- Workflow (e): a recurring task with a checklist. Completing it must roll the +-- task forward in place and present the next occurrence with a FRESH, +-- all-unchecked checklist — completion never carries forward (tech-spec §4.4). +-- +-- The e2e daemon uses the real system clock (it's the actual binary), so we +-- assert the do-date *advanced* past the original rather than an exact value; +-- the fresh checklist is the hard requirement. + +local h = require("e2e.helpers") + +describe("recurring task checklist", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("resets the checklist to all-unchecked on the next occurrence", function() + local base = 1717200000000 -- 2024-06-01, safely in the past + local task = require("heph.task").capture("Water plants", { + recurrence = "FREQ=DAILY", + do_date = base, + }) + + -- Its canonical-context doc holds the checklist (the recurrence template). + local ctx_id + for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do + if l.link_type == "canonical-context" then + ctx_id = l.dst_id + end + end + assert.is_truthy(ctx_id) + + -- Add a checklist via the buffer, then check both items off. + local buf = h.open(ctx_id) + h.set_lines(buf, { "- [ ] water fern", "- [ ] water cactus" }) + h.save(buf) + h.set_lines(buf, { "- [x] water fern", "- [x] water cactus" }) + h.save(buf) + + -- Complete the occurrence from its context buffer → rolls forward in place. + require("heph.task").set_state_current("done") + + local t = ctx.q:call("task.get", { id = task.node_id }) + assert.are.equal("outstanding", t.state) -- rolled forward, not done + assert.is_true(t.do_date > base, "do-date should advance to the next occurrence") + + -- The hard requirement: a fresh, all-unchecked checklist. + local stored = ctx.q:call("node.get", { id = ctx_id }) + assert.are.equal("- [ ] water fern\n- [ ] water cactus", stored.body) + end) +end)