heph.nvim: context-item promotion + Dagger headless-nvim CI (slice 11c)
Some checks failed
Build / validate (pull_request) Failing after 3s

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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-02 06:08:41 -07:00
commit b97c387252
21 changed files with 435 additions and 10 deletions

View file

@ -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."""

View file

@ -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=.

View file

@ -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

View file

@ -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<usize> {
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";

View file

@ -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<String> = 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),

View file

@ -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<Attention>,
project_id: Option<String>,
) -> Result<Task> {
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<Vec<RankedTask>> {
let now = self.clock.now_ms();
tasks::next(&self.conn, &self.owner_id, now, scope, limit)

View file

@ -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<Attention>,
project_id: Option<String>,
) -> Result<Task> {
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<String> = 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<Option<Task>> {
let task = conn

View file

@ -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<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.
fn promote(
&mut self,
container_id: &str,
item_ref: usize,
attention: Option<Attention>,
project_id: Option<String>,
) -> Result<Task>;
/// 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`.

View file

@ -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();

View file

@ -160,6 +160,24 @@ impl Store for RemoteStore {
)
}
fn promote(
&mut self,
container_id: &str,
item_ref: usize,
attention: Option<Attention>,
project_id: Option<String>,
) -> Result<Task> {
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<Vec<heph_core::RankedTask>> {
self.call_as("next", json!({ "scope": scope, "limit": limit }))
}

View file

@ -135,6 +135,16 @@ struct SetAttentionParams {
attention: Attention,
}
#[derive(Deserialize)]
struct PromoteParams {
container_id: String,
item_ref: usize,
#[serde(default)]
attention: Option<Attention>,
#[serde(default)]
project: Option<String>,
}
#[derive(Deserialize)]
struct NextParams {
#[serde(default)]
@ -255,6 +265,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: IdParam = parse(params)?;
json!(store.skip_recurrence(&p.id)?)
}
"task.promote" => {
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))?)

View file

@ -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();

View file

@ -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/<id>` with `BufReadCmd``node.get` / `BufWriteCmd``node.update`, whole-buffer body round-tripping exactly through the CRDT); `[[wiki-link]]` follow on `<CR>` 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 (`<CR>` 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.

View file

@ -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 <title>` | 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

View file

@ -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 11a11b).
> 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 11a11c).
**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 (11a11b done). The rest are non-blocking polish + an end-of-v1 sweep (§11).
> The Rust backend is feature-complete; `heph.nvim` slices 11a11c 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.

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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")