From b97c387252c5032a43e496eff2569ff8c04c0a9e Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 2 Jun 2026 06:08:41 -0700 Subject: [PATCH] heph.nvim: context-item promotion + Dagger headless-nvim CI (slice 11c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (TDD): - task.promote {container_id, item_ref, attention?, project?}: mint a committed task from the item_ref-th `- [ ]` context item (1-based, document order via a new extract::context_item_lines) and rewrite that source line into a [[link]] to it. Unit + rpc_socket tests. - resolve_id now excludes canonical-context docs, so [[Task Title]] resolves to the task, not its identically-titled context doc (deterministic; a general fix surfaced by promotion's ULID-tiebreak ambiguity). Plugin: :Heph promote / promote_under_cursor (save-if-dirty → compute item index with a code-fence-aware scanner mirroring extract.rs → task.promote → reload the rewritten buffer). e2e spec (f): promote a context line, assert the new task in next, the source line became a link, and the container backlinks the task. CI via Dagger: a test_nvim function bakes a pinned, arch-detected Neovim (v0.11.2 — Debian's is too old for vim.uv) onto rust:1-bookworm, builds hephd, and runs the self-contained shim suite (cargo + target cache volumes); build.yaml calls `dagger call test-nvim`. run.lua now fails on zero specs (no false-green). Validated end-to-end: passing suite → exit 0, failing spec → Dagger exit 1. 117 Rust tests + 7 nvim e2e specs green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .dagger/src/hephaestus_ci/main.py | 54 +++++++++++++++++ .forgejo/workflows/build.yaml | 5 ++ README.md | 2 +- crates/heph-core/src/extract.rs | 25 ++++++++ crates/heph-core/src/sqlite/links.rs | 6 ++ crates/heph-core/src/sqlite/mod.rs | 19 ++++++ crates/heph-core/src/sqlite/tasks.rs | 70 +++++++++++++++++++++++ crates/heph-core/src/store.rs | 12 ++++ crates/heph-core/tests/tasks_and_links.rs | 38 ++++++++++++ crates/hephd/src/remote.rs | 18 ++++++ crates/hephd/src/rpc.rs | 14 +++++ crates/hephd/tests/rpc_socket.rs | 39 +++++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/heph-nvim.md | 17 ++++-- docs/reference/tech-spec.md | 8 +-- heph.nvim/lua/heph/command.lua | 3 + heph.nvim/lua/heph/config.lua | 3 + heph.nvim/lua/heph/task.lua | 30 ++++++++++ heph.nvim/lua/heph/util.lua | 25 ++++++++ heph.nvim/tests/e2e/promote_spec.lua | 46 +++++++++++++++ heph.nvim/tests/e2e/run.lua | 10 +++- 21 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 heph.nvim/tests/e2e/promote_spec.lua diff --git a/.dagger/src/hephaestus_ci/main.py b/.dagger/src/hephaestus_ci/main.py index e129a44..1833f05 100644 --- a/.dagger/src/hephaestus_ci/main.py +++ b/.dagger/src/hephaestus_ci/main.py @@ -1,9 +1,63 @@ import dagger from dagger import dag, function, object_type +# Pinned Neovim — Debian's packaged nvim is far too old for `vim.uv` (the plugin +# needs >= 0.10), so the e2e container bakes an official release tarball. The +# arch is detected at build time so this runs natively on amd64 (CI) and arm64. +NVIM_VERSION = "v0.11.2" + @object_type class HephaestusCi: + @function + async def test_nvim(self, src: dagger.Directory) -> str: + """Run the heph.nvim headless e2e suite (tech-spec §9). + + Builds the hephd daemon and drives the plugin in headless Neovim against + it, using the repo's self-contained busted-style runner (no external nvim + plugins, no network at test time). Fails non-zero if any spec fails; + returns the suite output. Dev runs the same suite natively via + `mise run test-nvim`; this is the reproducible CI path. + """ + return await ( + dag.container() + .from_("rust:1-bookworm") + .with_exec(["apt-get", "update", "-qq"]) + .with_exec(["apt-get", "install", "-y", "-qq", "curl", "ca-certificates"]) + # Cache cargo downloads + build artifacts across CI runs. + .with_mounted_cache( + "/usr/local/cargo/registry", + dag.cache_volume("heph-cargo-registry"), + ) + .with_exec( + [ + "sh", + "-c", + "set -e; " + 'case "$(uname -m)" in ' + "x86_64) arch=x86_64 ;; " + "aarch64|arm64) arch=arm64 ;; " + '*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; ' + "esac; " + "curl -fsSL " + f"https://github.com/neovim/neovim/releases/download/{NVIM_VERSION}/nvim-linux-$arch.tar.gz " + "| tar -xz -C /opt; " + "ln -s /opt/nvim-linux-$arch /opt/nvim", + ] + ) + .with_env_variable("PATH", "/opt/nvim/bin:$PATH", expand=True) + .with_directory("/workspace", src) + .with_workdir("/workspace") + .with_mounted_cache("/workspace/target", dag.cache_volume("heph-target")) + .with_exec(["cargo", "build", "-p", "hephd"]) + .with_env_variable("HEPHD_BIN", "/workspace/target/debug/hephd") + .with_workdir("/workspace/heph.nvim") + .with_exec( + ["nvim", "--headless", "-u", "NONE", "-c", "luafile tests/e2e/run.lua"] + ) + .stdout() + ) + @function async def build_docs(self, src: dagger.Directory, version: str) -> dagger.File: """Build Quartz docs site. Returns docs tarball.""" diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 5180340..3ff12f4 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -42,3 +42,8 @@ jobs: else echo "No .forgejo/scripts/build hook found; template validation complete." fi + + - name: Run heph.nvim e2e (Dagger) + run: | + echo "Running headless heph.nvim e2e suite via Dagger..." + dagger call test-nvim --src=. diff --git a/README.md b/README.md index 143d637..fe8e7e3 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision | 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` — Tactical/Organizational task views, capture, attention, done/drop, log (slice 11b) | ✅ done | -| `heph.nvim` — context-item promotion + Dagger CI runner (slice 11c) | ⏳ next | +| `heph.nvim` — context-item promotion + Dagger headless-nvim CI (slice 11c) | ✅ done | ## Architecture diff --git a/crates/heph-core/src/extract.rs b/crates/heph-core/src/extract.rs index cf6f4e4..24b32aa 100644 --- a/crates/heph-core/src/extract.rs +++ b/crates/heph-core/src/extract.rs @@ -102,6 +102,22 @@ pub fn extract(body: &str) -> Extraction { } } +/// The 0-based body line index of each context item, in the **same document +/// order** as [`extract`]'s `context_items` (task markers never fire inside code +/// blocks, so the two lists align 1:1). Promotion uses this to locate the source +/// `- [ ]` line it must rewrite into a link (tech-spec §4.3, §6). +pub fn context_item_lines(body: &str) -> Vec { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TASKLISTS); + let mut lines = Vec::new(); + for (event, range) in Parser::new_ext(body, options).into_offset_iter() { + if let Event::TaskListMarker(_) = event { + lines.push(body[..range.start].bytes().filter(|&b| b == b'\n').count()); + } + } + lines +} + /// Find `[[target]]` (or `[[target|display]]`) spans in `body`, returning each /// unique, non-empty target in first-seen order. Matches starting inside a /// `code` range are skipped. The `[` / `]` delimiters are ASCII, so byte @@ -218,6 +234,15 @@ mod tests { assert!(!e.context_items[0].checked); } + #[test] + fn context_item_lines_align_with_items_skipping_code() { + let body = "# Notes\n\n- [ ] first\n\n```\n- [ ] fenced\n```\n\n- [x] second\n"; + let lines = context_item_lines(body); + // Two real items (the fenced one is skipped, matching `context_items`). + assert_eq!(lines.len(), extract(body).context_items.len()); + assert_eq!(lines, vec![2, 8]); // 0-based lines of "- [ ] first" / "- [x] second" + } + #[test] fn extraction_is_idempotent() { let body = "# Mixed\n\n- [ ] do [[X]]\n- [x] done\n\nsee [[Y]]\n"; diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 0557464..c5fa04d 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -174,10 +174,16 @@ pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result if by_alias.is_some() { return Ok(by_alias); } + // A title may be shared by a task and its canonical-context doc (they are + // created with the same title). Wiki-links address the first-class node, so + // canonical-context docs are excluded — `[[Task Title]]` resolves to the + // task, never its internal context attachment (deterministic; tech-spec §6). let by_title: Option = conn .query_row( "SELECT id FROM nodes WHERE title = ?1 AND owner_id = ?2 AND tombstoned = 0 + AND id NOT IN (SELECT dst_id FROM links + WHERE type = 'canonical-context' AND tombstoned = 0) ORDER BY created_at, id LIMIT 1", (target, owner), |r| r.get(0), diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 7ed2af2..9c88975 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -231,6 +231,25 @@ impl Store for LocalStore { tasks::set_attention(&self.conn, &self.owner_id, now, node_id, attention) } + fn promote( + &mut self, + container_id: &str, + item_ref: usize, + attention: Option, + project_id: Option, + ) -> Result { + let now = self.clock.now_ms(); + tasks::promote( + &mut self.conn, + &self.owner_id, + now, + container_id, + item_ref, + attention, + project_id, + ) + } + fn next(&self, scope: Option<&str>, limit: usize) -> Result> { let now = self.clock.now_ms(); tasks::next(&self.conn, &self.owner_id, now, scope, limit) diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 4a5164f..351e0cb 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -9,6 +9,7 @@ use serde_json::json; use super::{links, log, next_hlc, nodes, ops}; use crate::error::{Error, Result}; +use crate::extract; use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState}; use crate::oplog::op_type; use crate::ranking::{self, RankedTask}; @@ -155,6 +156,75 @@ pub(super) fn create(conn: &mut Connection, owner: &str, now: i64, input: NewTas Ok(task) } +/// Promote a `- [ ]` context-item line in `container_id`'s body into a committed +/// task, rewriting that source line into a `[[link]]` to the new task (Fork A, +/// tech-spec §4.3, §6). `item_ref` is the **1-based index** of the item among +/// the container's context items in document order (code-fence-aware, matching +/// extraction). +pub(super) fn promote( + conn: &mut Connection, + owner: &str, + now: i64, + container_id: &str, + item_ref: usize, + attention: Option, + project_id: Option, +) -> Result { + let container = + nodes::get(conn, container_id)?.ok_or_else(|| Error::NodeNotFound(container_id.into()))?; + let body = container.body.unwrap_or_default(); + + let idx = item_ref + .checked_sub(1) + .ok_or_else(|| Error::Integrity("item_ref is 1-based".into()))?; + let item = extract::extract(&body) + .context_items + .into_iter() + .nth(idx) + .ok_or_else(|| Error::Integrity(format!("no context item #{item_ref} to promote")))?; + let line = *extract::context_item_lines(&body) + .get(idx) + .ok_or_else(|| Error::Integrity(format!("no context item #{item_ref} to promote")))?; + let title = item.text.trim().to_string(); + if title.is_empty() { + return Err(Error::Integrity( + "cannot promote an empty context item".into(), + )); + } + + // Mint the committed task (its own node + canonical context doc + link). + let task = create( + conn, + owner, + now, + NewTask { + title: title.clone(), + attention, + project_id, + ..Default::default() + }, + )?; + + // Rewrite the source line into a wiki-link to the new task. Updating the + // container re-runs extraction, materializing the container→task `wiki` link + // and dropping the now-promoted context item. + let new_body = rewrite_line(&body, line, &format!("- [[{title}]]")); + nodes::update(conn, owner, now, container_id, None, Some(new_body))?; + + Ok(task) +} + +/// Replace body line `idx` (0-based) with `new_line`, preserving the original +/// line's leading whitespace. An out-of-range `idx` leaves the body unchanged. +fn rewrite_line(body: &str, idx: usize, new_line: &str) -> String { + let mut lines: Vec = body.split('\n').map(str::to_string).collect(); + if let Some(slot) = lines.get_mut(idx) { + let indent: String = slot.chars().take_while(|c| c.is_whitespace()).collect(); + *slot = format!("{indent}{new_line}"); + } + lines.join("\n") +} + /// Fetch a task by node id. pub(super) fn get(conn: &Connection, node_id: &str) -> Result> { let task = conn diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index a5137f9..5113b61 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -71,6 +71,18 @@ pub trait Store { /// Set a task's attention-state. fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result; + /// Promote a `- [ ]` context-item line in `container_id`'s body into a + /// committed task, rewriting that source line into a `[[link]]` to the new + /// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the + /// item among the container's context items in document order. + fn promote( + &mut self, + container_id: &str, + item_ref: usize, + attention: Option, + project_id: Option, + ) -> Result; + /// The Tactical "what is next?" ranking (tech-spec §7), using the store's /// injected clock as `now`. `scope`, when `Some`, restricts to a project /// node id; `red` items always appear regardless of `limit`. diff --git a/crates/heph-core/tests/tasks_and_links.rs b/crates/heph-core/tests/tasks_and_links.rs index ee17402..e80a2e4 100644 --- a/crates/heph-core/tests/tasks_and_links.rs +++ b/crates/heph-core/tests/tasks_and_links.rs @@ -9,6 +9,44 @@ fn store() -> LocalStore { LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() } +#[test] +fn promote_mints_a_task_and_rewrites_the_line_into_a_link() { + let mut s = store(); + // A container doc with two context items. + let container = s + .create_node(NewNode::doc( + "Errands", + "- [ ] call plumber\n- [ ] water plants", + )) + .unwrap(); + + // Promote the first item. + let task = s + .promote(&container.id, 1, Some(Attention::Orange), None) + .unwrap(); + + // A committed task now exists with the item's text as its title. + let node = s.get_node(&task.node_id).unwrap().unwrap(); + assert_eq!(node.kind, NodeKind::Task); + assert_eq!(node.title, "call plumber"); + assert_eq!(task.attention, Some(Attention::Orange)); + assert_eq!(task.state, TaskState::Outstanding); + + // The source line became a wiki-link; the other item is untouched. + let body = s.get_node(&container.id).unwrap().unwrap().body.unwrap(); + assert_eq!(body, "- [[call plumber]]\n- [ ] water plants"); + + // That link resolves to the new task and is materialized as a backlink. + assert_eq!( + s.resolve_node("call plumber").unwrap().unwrap().id, + task.node_id + ); + let backs = s.backlinks(&task.node_id).unwrap(); + assert!(backs + .iter() + .any(|l| l.src_id == container.id && l.link_type == LinkType::Wiki)); +} + #[test] fn create_task_makes_a_canonical_context_doc_and_link() { let mut s = store(); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 3d3cdae..8445292 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -160,6 +160,24 @@ impl Store for RemoteStore { ) } + fn promote( + &mut self, + container_id: &str, + item_ref: usize, + attention: Option, + project_id: Option, + ) -> Result { + self.call_as( + "task.promote", + json!({ + "container_id": container_id, + "item_ref": item_ref, + "attention": attention, + "project": project_id, + }), + ) + } + fn next(&self, scope: Option<&str>, limit: usize) -> Result> { self.call_as("next", json!({ "scope": scope, "limit": limit })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 874b98b..b4df8a8 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -135,6 +135,16 @@ struct SetAttentionParams { attention: Attention, } +#[derive(Deserialize)] +struct PromoteParams { + container_id: String, + item_ref: usize, + #[serde(default)] + attention: Option, + #[serde(default)] + project: Option, +} + #[derive(Deserialize)] struct NextParams { #[serde(default)] @@ -255,6 +265,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: PromoteParams = parse(params)?; + json!(store.promote(&p.container_id, p.item_ref, p.attention, p.project)?) + } "next" => { let p: NextParams = parse(params)?; json!(store.next(p.scope.as_deref(), p.limit.unwrap_or(DEFAULT_LIMIT))?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 47abfa9..7da9264 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -134,6 +134,45 @@ fn task_create_appears_in_next_with_context_link() { assert_eq!(doc["kind"], "doc"); } +#[test] +fn promote_context_item_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let container = c + .call( + "node.create", + json!({ "kind": "doc", "title": "Errands", "body": "- [ ] call plumber\n- [ ] water plants" }), + ) + .unwrap(); + let container_id = container["id"].as_str().unwrap().to_string(); + + // Promote the first context item to a committed task. + let task = c + .call( + "task.promote", + json!({ "container_id": container_id, "item_ref": 1, "attention": "orange" }), + ) + .unwrap(); + let task_id = task["node_id"].as_str().unwrap().to_string(); + + // It appears in `next`, and the source line became a link to it. + let ranked = c.call("next", json!({ "limit": 10 })).unwrap(); + assert!(ranked + .as_array() + .unwrap() + .iter() + .any(|t| t["node_id"] == task_id && t["title"] == "call plumber")); + + let body = c.call("node.get", json!({ "id": container_id })).unwrap(); + assert_eq!(body["body"], "- [[call plumber]]\n- [ ] water plants"); + + let resolved = c + .call("node.resolve", json!({ "title": "call plumber" })) + .unwrap(); + assert_eq!(resolved["id"], task_id); +} + #[test] fn errors_are_reported_as_rpc_errors() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 919c122..a69ef67 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -16,3 +16,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - 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). +- `heph.nvim` slice 11c (§8) — promotion + CI: `task.promote` mints a committed task from a `- [ ]` context-item line (addressed by its 1-based index) and rewrites that line into a `[[link]]` to the new task; `:Heph promote` does this for the line under the cursor. Wiki-link resolution now excludes a task's canonical-context doc, so `[[Task Title]]` resolves to the task itself (not its identically-titled context doc). The headless e2e suite runs in CI via a Dagger function that bakes a pinned, arch-detected Neovim onto a Rust image and runs the same self-contained suite developers run natively with `mise run test-nvim`; the runner fails on a zero-spec discovery so a misconfigured path can't pass silently. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index a669055..3bfb903 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -40,7 +40,7 @@ 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 11b) +## Commands (as of slice 11c) | Command | Action | |---|---| @@ -53,6 +53,7 @@ node the stored link points at. | `: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 promote [attention]` | Promote the `- [ ]` line under the cursor to a committed task | | `:Heph log <text>` | Append a breadcrumb to the current task's log | "Current task" is resolved from the buffer: a `task` node, or a canonical-context @@ -61,7 +62,12 @@ doc whose owning task is followed via its `canonical-context` backlink. The 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. +**Promotion** (`:Heph promote`) mints a committed task from the `- [ ]` line +under the cursor (the daemon's `task.promote`, `item_ref` = the cursor item's +1-based index among context items, computed code-fence-aware to mirror +`extract.rs`) and rewrites that line into a `[[link]]` to the new task. To keep +that link unambiguous, wiki-link resolution excludes canonical-context docs, so +`[[Task Title]]` resolves to the task, not its identically-titled context doc. ## Testing (tech-spec §9) @@ -71,8 +77,11 @@ state (via an isolated RPC session). It uses a **self-contained busted-style runner** (`tests/e2e/runner.lua`) — no external plugins, no network — so it is deterministic. `mise run test-nvim` builds the daemon and runs the suite against system-installed Neovim; a deliberately failing spec exits non-zero (no -false-green). In CI the same suite runs inside a Dagger container that provides -Neovim + the Rust toolchain (slice 11c). +false-green; the runner also fails if it discovers zero specs). CI runs the same +suite through the **`test-nvim` Dagger function** (`.dagger/`, invoked by +`build.yaml` as `dagger call test-nvim`), which bakes a pinned, arch-detected +Neovim onto a Rust image, builds `hephd`, and runs the suite — reproducible, and +identical to the native `mise run test-nvim` path. ## Related diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 1009e34..4f80f89 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 — **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). +> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-02 — **117 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`mise run test-nvim`, 7 specs; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slices 11a–11c). **Done** @@ -347,13 +347,13 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **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). +- ✅ **`heph.nvim` slice 11c (§8) — promotion + Dagger CI:** backend **`task.promote {container_id, item_ref, attention?, project?}`** — mints a committed task from the `item_ref`-th `- [ ]` context item (1-based, document order via a new `extract::context_item_lines`) and rewrites that source line into a `[[link]]` to it. **Wiki-link resolution now excludes canonical-context docs** (`resolve_id`), so `[[Task Title]]` deterministically resolves to the task, not its identically-titled context doc — a general fix surfaced by promotion. Plugin: `:Heph promote`, `promote_under_cursor` (save-if-dirty → `util.context_item_index_at_cursor` mirrors extract's fence rules → `task.promote` → reload). e2e spec (f). **CI via Dagger:** a `test_nvim` function in `.dagger/` bakes a **pinned, arch-detected Neovim** (`v0.11.2`; Debian's is too old for `vim.uv`) onto `rust:1-bookworm`, builds `hephd`, and runs the shim suite (cargo + cargo-target cache volumes); `build.yaml` calls `dagger call test-nvim`. `run.lua` fails on zero-specs (no false-green) — validated end-to-end (failing spec → Dagger exit 1). **Not yet done (resume order)** -> 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). +> The Rust backend is feature-complete; `heph.nvim` slices 11a–11c are done — the v1 surface (knowledge base + task views + promotion) works end-to-end with CI. The remainder is the deferred reconcile slice plus non-blocking polish + an end-of-v1 sweep (§11). -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). +1. ⏳ **`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. diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index b16133a..028a93d 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -80,6 +80,9 @@ M.subs = { skip = function() require("heph.task").skip_current() end, + promote = function(args) + require("heph.task").promote_under_cursor({ attention = args[1] }) + end, log = function(args) require("heph.task").log_append_current(table.concat(args, " ")) end, diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua index ddc1dd4..8a50a0c 100644 --- a/heph.nvim/lua/heph/config.lua +++ b/heph.nvim/lua/heph/config.lua @@ -42,6 +42,9 @@ function M.apply_keymaps(opts) map("n", "<leader>hd", function() require("heph.task").set_state_current("done") end, { desc = "heph: mark current task done" }) + map("n", "<leader>hp", function() + require("heph.task").promote_under_cursor() + end, { desc = "heph: promote context item to a task" }) end return M diff --git a/heph.nvim/lua/heph/task.lua b/heph.nvim/lua/heph/task.lua index 87be1c5..5a4d005 100644 --- a/heph.nvim/lua/heph/task.lua +++ b/heph.nvim/lua/heph/task.lua @@ -76,6 +76,36 @@ function M.skip_current() end, "occurrence skipped") end +--- Promote the context-item line under the cursor to a committed task; the +--- daemon rewrites that line into a `[[link]]` to the new task. `opts.attention` +--- optional. Returns the created task. +function M.promote_under_cursor(opts) + opts = opts or {} + local buf = vim.api.nvim_get_current_buf() + local container_id = vim.b[buf].heph_node_id + if not container_id then + util.notify("not in a heph node buffer", vim.log.levels.WARN) + return nil + end + -- Save first so the daemon's body matches what the user sees on screen. + if vim.bo[buf].modified then + require("heph.node").write(buf, vim.api.nvim_buf_get_name(buf)) + end + local item_ref = util.context_item_index_at_cursor(buf) + if not item_ref then + util.notify("cursor is not on a context item (- [ ])", vim.log.levels.WARN) + return nil + end + local task = rpc.call("task.promote", { + container_id = container_id, + item_ref = item_ref, + attention = opts.attention, + }) + require("heph.node").reload(container_id) -- pull the rewritten body + util.notify("promoted to a task") + return task +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 diff --git a/heph.nvim/lua/heph/util.lua b/heph.nvim/lua/heph/util.lua index 3e22a0b..5d925c0 100644 --- a/heph.nvim/lua/heph/util.lua +++ b/heph.nvim/lua/heph/util.lua @@ -18,6 +18,31 @@ function M.parse_uri(uri) return uri:match("^heph://([^/]+)/(.+)$") end +--- The 1-based index of the context item on the cursor's line among the +--- buffer's context items (document order, fenced code skipped) — the `item_ref` +--- for `task.promote`. Mirrors heph-core's `extract.rs` ordering. Returns nil if +--- the cursor line is not a `- [ ]` / `- [x]` item. +function M.context_item_index_at_cursor(buf) + local cur = vim.api.nvim_win_get_cursor(0)[1] + local lines = vim.api.nvim_buf_get_lines(buf, 0, cur, false) -- lines 1..cursor + local in_fence, count, last_is_item = false, 0, false + for _, line in ipairs(lines) do + if line:match("^%s*```") then + in_fence = not in_fence + last_is_item = false + elseif not in_fence and line:match("^%s*[-*+]%s+%[[ xX]%]") then + count = count + 1 + last_is_item = true + else + last_is_item = false + end + end + if last_is_item then + return count + end + return nil +end + --- Notify with a consistent `heph:` prefix. function M.notify(msg, level) vim.notify("heph: " .. msg, level or vim.log.levels.INFO) diff --git a/heph.nvim/tests/e2e/promote_spec.lua b/heph.nvim/tests/e2e/promote_spec.lua new file mode 100644 index 0000000..df7e625 --- /dev/null +++ b/heph.nvim/tests/e2e/promote_spec.lua @@ -0,0 +1,46 @@ +-- Workflow (f): promote a context-item line to a committed task. The daemon +-- mints the task and rewrites the source line into a [[link]] to it. + +local h = require("e2e.helpers") + +describe("promote context item", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("mints a task, links the source line, and surfaces it in next", function() + local container = h.create_doc("Errands", "- [ ] call plumber") + local buf = h.open(container.id) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) -- on the context-item line + + local task = require("heph.task").promote_under_cursor({ attention = "orange" }) + assert.is_truthy(task.node_id) + + -- The promoted task appears in the Tactical ranking. + local found = false + for _, t in ipairs(ctx.q:call("next", { limit = 10 })) do + if t.node_id == task.node_id then + found = true + end + end + assert.is_true(found, "promoted task missing from next") + + -- The source line became a wiki-link to the task (persisted + in-buffer). + assert.are.equal("- [[call plumber]]", ctx.q:call("node.get", { id = container.id }).body) + local reloaded = vim.api.nvim_get_current_buf() + assert.are.equal("- [[call plumber]]", vim.api.nvim_buf_get_lines(reloaded, 0, 1, false)[1]) + + -- ...and the container backlinks the task. + local linked = false + for _, l in ipairs(ctx.q:call("links.backlinks", { id = task.node_id })) do + if l.src_id == container.id and l.link_type == "wiki" then + linked = true + end + end + assert.is_true(linked, "expected a wiki backlink from the container to the task") + end) +end) diff --git a/heph.nvim/tests/e2e/run.lua b/heph.nvim/tests/e2e/run.lua index 0b71254..d3d0a6e 100644 --- a/heph.nvim/tests/e2e/run.lua +++ b/heph.nvim/tests/e2e/run.lua @@ -17,6 +17,14 @@ runner.install_globals() local files = vim.fn.glob(e2e .. "/*_spec.lua", false, true) table.sort(files) -local failed = runner.run_files(files) +-- Guard against a false green: zero specs found (e.g. a path/glob mistake in a +-- container) must fail, not pass silently. +if #files == 0 then + io.stderr:write("heph e2e: no *_spec.lua found under " .. e2e .. "\n") + vim.cmd("cquit 1") + return +end + +local failed = runner.run_files(files) vim.cmd(failed > 0 and "cquit 1" or "quit")