feat(nvim): frontmatter edit surface — diff block into RPCs on save (§8.3)
Some checks failed
Build / validate (pull_request) Failing after 5m1s

Node buffers now open with the editable YAML frontmatter block on top
(node.get {frontmatter: true}); on :w, `frontmatter.lua` parses the
block, diffs it against what was rendered, and routes each changed field
to the right RPC:
- title → node.update rename
- attention → task.set_attention
- do_date/late_on/recurrence → task.set_schedule (YYYY-MM-DD → local-ms;
  a removed line clears via null)
- project → task.set_project (resolved by name)
- tags → tag.add / tag.remove
A mistyped state surfaces the daemon's validation error; a buffer with no
block edits no metadata (deleting the block can't wipe tags). Body rides
node.update as before (the store strips any echoed frontmatter).

Body-position features are content-relative, so the prepended block
doesn't disturb them; e2e specs that targeted absolute line 1 now locate
body lines by content via a new `h.find` helper. New frontmatter_spec
covers render + the full diff→RPC round-trip. 21 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-03 11:44:39 -07:00
commit 0e9cfc1fd7
9 changed files with 328 additions and 23 deletions

View file

@ -28,5 +28,6 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit.
- `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.)
- `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc.
- Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Link-follow and promotion are unaffected (they're content-relative, not line-absolute).
- Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next).
- Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface.

View file

@ -79,6 +79,14 @@ edits, else adding the `wiki` link directly).
| `: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 |
A node buffer opens with an editable **YAML frontmatter** block on top (`id`,
`kind`, `title`, `tags`, and — for a task or its context doc — the task's
`state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:`
ref). On `:w`, `frontmatter.lua` diffs the block against what was rendered and
issues the right RPC per changed field (rename, `set_attention`, `set_schedule`,
`set_project`, `tag.add`/`tag.remove`); the store strips the block so it never
enters the body (tech-spec §8.3). A buffer with no block edits no metadata.
"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

View file

@ -300,14 +300,14 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2.
> **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1.
## 8.3 Frontmatter as an edit surface (backend built; nvim diff layer next)
## 8.3 Frontmatter as an edit surface (built)
> **Status: backend built** (the projection); the `heph.nvim` diff layer is next. When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body.
> **Status: built** (inline `#hashtags` on save are a small deferred follow-up). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block atop the body — without that metadata ever becoming a second, drifting source of truth in the body.
The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor:
- ✅ **The store is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. `heph-core::frontmatter::strip(body)` runs inside `update_node` **before** the `yrs` CRDT diff (conservative — it only removes a leading `---` block whose first line is a YAML key, so a leading `---` thematic break in prose survives; idempotent). The **render** side lives in `hephd` (`hephd::frontmatter::render`, since formatting `do_date`/`late_on` for humans needs the local timezone via `datespec::fmt_iso`); `node.get {frontmatter: true}` prepends it. Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and the store drops it; the canonical block regenerates on the next read.
- **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention``set_attention`, `do_date`/`late_on`/`recurrence``set_schedule`, `project`**`set_project`** (§8.1), `tags``tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core.
- **`heph.nvim` is the smart client.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention``set_attention`, `do_date`/`late_on`/`recurrence``set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project`**`set_project`** (resolved by name, §8.1), `tags``tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.) **Remaining:** inline `#hashtags` detected on save.
**The schema** (a curated, *editable* subset — not the full export snapshot). `id`/`kind` are read-only; `title` and `tags` edit the opened node; when the node **is** a task or backs one (its canonical-context doc), the owning task's scalars are rendered and a read-only `task:` id says where those edits route:
@ -468,7 +468,7 @@ See [[design]] §5§7 for the constraints later phases impose on present choi
- ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.)
- ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit).
3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3).
4. **YAML frontmatter as an edit surface (§8.3) — backend DONE, nvim next:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref. Round-trip is a no-op; inbound frontmatter is always stripped (safe vs any client). ⏳ Remaining: the `heph.nvim` diff-on-`BufWriteCmd` → structured RPCs (`title`→rename, `attention`→set_attention, dates→set_schedule, `project`→set_project, `tags`→tag.add/remove) + inline `#hashtags` on save.
4. **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata). **Deferred follow-up:** inline `#hashtags` detected on save.
5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4.
6. ⏳ **`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).
7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]).

View file

@ -0,0 +1,172 @@
--- Frontmatter as an edit surface (tech-spec §8.3). The daemon renders an
--- editable YAML block atop a node's body on read (`node.get {frontmatter}`)
--- and strips it on write; this module is the **smart-client** half: parse the
--- buffer's frontmatter and translate each changed field into the right
--- structured RPC (title→rename, attention→set_attention, dates→set_schedule,
--- project→set_project, tags→tag.add/remove). The body itself rides `node.update`.
local rpc = require("heph.rpc")
local M = {}
local function trim(s)
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
--- Unquote a YAML scalar (`"…"` with `\"`/`\\` escapes), else return it trimmed.
local function unquote(s)
s = trim(s)
local inner = s:match('^"(.*)"$')
if inner then
return (inner:gsub('\\"', '"'):gsub("\\\\", "\\"))
end
return s
end
--- Parse a YAML flow sequence `[a, b]` (or `[]`) into a list of scalars.
local function parse_flow_seq(s)
local inner = s:match("^%[(.*)%]$")
if not inner then
return {}
end
local out = {}
for item in (inner .. ","):gmatch("(.-),") do
item = trim(item)
if item ~= "" then
out[#out + 1] = unquote(item)
end
end
return out
end
--- Parse a leading `---` frontmatter block. Returns `(fm, body)` where `fm` is a
--- table of string fields plus `tags` (a list), or `(nil, text)` when there is
--- no conforming block (so a frontmatter-less buffer never looks like metadata).
function M.parse(text)
if text:sub(1, 4) ~= "---\n" then
return nil, text
end
local lines = vim.split(text, "\n", { plain = true })
local close
for i = 2, #lines do
if lines[i] == "---" then
close = i
break
end
end
if not close then
return nil, text
end
local fm = { tags = {} }
for j = 2, close - 1 do
local key, val = lines[j]:match("^([%w_%-]+):%s*(.*)$")
if key == "tags" then
fm.tags = parse_flow_seq(val)
elseif key then
fm[key] = unquote(val)
end
end
local body = table.concat({ unpack(lines, close + 1) }, "\n")
return fm, body
end
--- A `YYYY-MM-DD` string → epoch ms at **local midnight** (matching the
--- daemon's `datespec`). Returns `nil, err` on a malformed date.
local function date_to_ms(s)
local y, m, d = tostring(s):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
if not y then
return nil, "expected a YYYY-MM-DD date, got " .. tostring(s)
end
return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0, min = 0, sec = 0 }) * 1000
end
--- Set difference of two scalar lists → (added, removed).
local function set_diff(old, new)
local oldset, newset = {}, {}
for _, x in ipairs(old or {}) do
oldset[x] = true
end
for _, x in ipairs(new or {}) do
newset[x] = true
end
local added, removed = {}, {}
for x in pairs(newset) do
if not oldset[x] then
added[#added + 1] = x
end
end
for x in pairs(oldset) do
if not newset[x] then
removed[#removed + 1] = x
end
end
return added, removed
end
--- Apply the difference between `canonical` (rendered on read) and the buffer's
--- `fm` by issuing the matching RPCs. `node_id` is the opened node (title/tags
--- target); task scalars route to the owning task (`fm.task`). Raises on any
--- RPC error (e.g. a mistyped `state` or an unknown `project`).
function M.apply(node_id, canonical, fm)
canonical = canonical or {}
-- Tags on the opened node.
local added, removed = set_diff(canonical.tags, fm.tags)
for _, t in ipairs(added) do
rpc.call("tag.add", { node_id = node_id, tag = t })
end
for _, t in ipairs(removed) do
rpc.call("tag.remove", { node_id = node_id, tag = t })
end
-- Task scalars route to the owning task, if this node is or backs one.
local task = fm.task or canonical.task
if task then
if fm.attention and fm.attention ~= canonical.attention then
rpc.call("task.set_attention", { id = task, attention = fm.attention })
end
-- Schedule (double-option): a present value sets, a removed line clears.
local patch, changed = { id = task }, false
local function sched(field)
if fm[field] == canonical[field] then
return
end
changed = true
if fm[field] == nil or fm[field] == "" then
patch[field] = vim.NIL -- cleared
elseif field == "recurrence" then
patch[field] = fm[field] -- a raw RRULE
else
local ms, err = date_to_ms(fm[field])
if not ms then
error("heph: " .. err)
end
patch[field] = ms
end
end
sched("do_date")
sched("late_on")
sched("recurrence")
if changed then
rpc.call("task.set_schedule", patch)
end
if fm.state and fm.state ~= canonical.state then
rpc.call("task.set_state", { id = task, state = fm.state }) -- a typo → rpc error
end
if fm.project ~= canonical.project then
local pid = vim.NIL -- removed / blank → unfile
if fm.project and fm.project ~= "" then
local node = rpc.call("node.resolve", { title = fm.project })
if not node then
error("heph: no node named '" .. fm.project .. "' to file under")
end
pid = node.id
end
rpc.call("task.set_project", { id = task, project_id = pid })
end
end
end
return M

View file

@ -1,26 +1,34 @@
--- Buffer-backed nodes (tech-spec §8): a node's markdown body is edited in a
--- real buffer named `heph://node/<id>`. `:e` loads it via `node.get`; `:w`
--- saves the whole buffer back via `node.update` (the backend CRDT-diffs the
--- whole-buffer text, so sending the full body is correct and idempotent).
--- real buffer named `heph://node/<id>`. `:e` loads it via `node.get`
--- (with the editable YAML **frontmatter** block prepended, §8.3); `:w` diffs
--- the frontmatter into structured RPCs, then saves the body via `node.update`
--- (the backend CRDT-diffs the whole body, and strips any frontmatter echoed
--- back — so sending the stripped body is correct and idempotent).
local rpc = require("heph.rpc")
local util = require("heph.util")
local frontmatter = require("heph.frontmatter")
local M = {}
--- `BufReadCmd` handler for `heph://node/<id>`: load the body into the buffer.
-- buf -> the frontmatter table rendered on read, so `write` can diff against it.
M._canonical = {}
--- `BufReadCmd` handler for `heph://node/<id>`: load frontmatter + body.
function M.read(buf, uri)
local _, id = util.parse_uri(uri)
if not id then
error("heph: not a node uri: " .. tostring(uri))
end
local node = rpc.call("node.get", { id = id })
local body = (node and node.body) or ""
local node = rpc.call("node.get", { id = id, frontmatter = true })
local text = (node and node.body) or ""
local fm = frontmatter.parse(text)
-- `plain` split keeps a trailing "" element for a trailing newline, so the
-- body round-trips exactly through `table.concat` on write.
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(body, "\n", { plain = true }))
-- text round-trips exactly through `table.concat` on write.
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(text, "\n", { plain = true }))
vim.b[buf].heph_node_id = id
vim.b[buf].heph_node_kind = (node and node.kind) or "doc"
M._canonical[buf] = fm or {}
vim.bo[buf].buftype = "acwrite" -- written via BufWriteCmd, not to a file
vim.bo[buf].filetype = "markdown"
vim.bo[buf].fileformat = "unix"
@ -28,14 +36,32 @@ function M.read(buf, uri)
require("heph.link").attach(buf)
end
--- `BufWriteCmd` handler: persist the whole buffer as the node body.
--- `BufWriteCmd` handler: route frontmatter edits to RPCs, then save the body.
function M.write(buf, _uri)
local id = vim.b[buf].heph_node_id
if not id then
error("heph: buffer has no heph node id")
end
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
rpc.call("node.update", { id = id, body = table.concat(lines, "\n") })
local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n")
local fm, body = frontmatter.parse(text)
local params = { id = id }
if fm then
-- A frontmatter block is present: translate its edits to RPCs. (Absent
-- block ⇒ the user removed it; treat the whole buffer as body, touch no
-- metadata.) The backend also strips defensively, so `body` is what stores.
local canonical = M._canonical[buf] or {}
frontmatter.apply(id, canonical, fm)
if fm.title and fm.title ~= canonical.title then
params.title = fm.title
end
params.body = body
M._canonical[buf] = fm
else
params.body = text
end
rpc.call("node.update", params)
vim.bo[buf].modified = false
end

View file

@ -16,11 +16,10 @@ describe("follow link", function()
local a = h.create_doc("A", "see [[B]] here")
local buf = h.open(a.id)
local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]
local open_at = line:find("%[%[B") -- start of "[[B"
assert.is_truthy(open_at)
local lnum, col = h.find(buf, "%[%[B") -- the body line, below the frontmatter
assert.is_truthy(lnum)
-- Put the cursor on the target inside the brackets.
vim.api.nvim_win_set_cursor(0, { 1, open_at + 1 })
vim.api.nvim_win_set_cursor(0, { lnum, col + 2 })
require("heph.link").follow()
@ -32,8 +31,8 @@ describe("follow link", function()
it("creates the target doc when following an unresolved [[link]]", function()
local a = h.create_doc("Daily", "see [[New Topic]]")
local buf = h.open(a.id)
local at = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]:find("%[%[New")
vim.api.nvim_win_set_cursor(0, { 1, at + 1 }) -- inside [[New Topic]]
local lnum, col = h.find(buf, "%[%[New")
vim.api.nvim_win_set_cursor(0, { lnum, col + 2 }) -- inside [[New Topic]]
require("heph.link").follow()

View file

@ -0,0 +1,85 @@
-- Frontmatter edit surface (tech-spec §8.3): a node buffer opens with an
-- editable YAML block on top, and saving routes each changed field to the right
-- structured RPC (rename / set_attention / set_schedule / set_project /
-- tag.add). The body itself still round-trips through node.update.
local h = require("e2e.helpers")
-- Replace `key: …` in the frontmatter `lines`, or insert it before the closing
-- `---` fence when the key isn't present yet.
local function set_field(lines, key, value)
for i, line in ipairs(lines) do
if line:match("^" .. key .. ":") then
lines[i] = key .. ": " .. value
return
end
end
local fences = 0
for i, line in ipairs(lines) do
if line == "---" then
fences = fences + 1
if fences == 2 then
table.insert(lines, i, key .. ": " .. value)
return
end
end
end
end
describe("frontmatter edit surface", function()
local ctx
before_each(function()
ctx = h.start()
end)
after_each(function()
h.stop(ctx)
end)
it("renders an editable block atop a node body", function()
local doc = h.create_doc("Roof", "# Roof\n\nnotes")
local buf = h.open(doc.id)
local first = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]
assert.are.equal("---", first, "buffer should open with a frontmatter fence")
assert.is_truthy(h.find(buf, "^title: Roof"), "title not in frontmatter")
assert.is_truthy(h.find(buf, "# Roof"), "body content missing below frontmatter")
end)
it("routes frontmatter edits to structured RPCs on save", function()
local proj = ctx.q:call("node.create", { kind = "project", title = "Camano" })
local task = ctx.q:call("task.create", { title = "Fix roof", attention = "red" })
local ctxid
for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do
if l.link_type == "canonical-context" then
ctxid = l.dst_id
end
end
local buf = h.open(ctxid)
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
set_field(lines, "title", "Fix the roof") -- rename the (doc) node
set_field(lines, "attention", "blue") -- → task.set_attention
set_field(lines, "tags", "[roofing]") -- → tag.add on the doc
set_field(lines, "project", "Camano") -- → task.set_project
set_field(lines, "do_date", "2026-06-10") -- → task.set_schedule
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
h.save(buf)
-- The task picked up attention, project, and a do-date.
local t = ctx.q:call("task.get", { id = task.node_id })
assert.are.equal("blue", t.attention)
assert.is_truthy(t.do_date, "do_date should be set")
local filed = false
for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do
if l.link_type == "in-project" and l.dst_id == proj.id then
filed = true
end
end
assert.is_true(filed, "task should be filed under Camano")
-- The opened node was renamed and tagged.
assert.are.equal("Fix the roof", ctx.q:call("node.get", { id = ctxid }).title)
local tags = ctx.q:call("tag.list", { node_id = ctxid })
assert.are.equal(1, #tags)
assert.are.equal("roofing", tags[1])
end)
end)

View file

@ -178,6 +178,19 @@ function M.open(id)
return vim.api.nvim_get_current_buf()
end
--- The (1-based line, 0-based byte col) of the first match of Lua `pattern` in
--- `buf`, or nil. Lets specs target body content by what it says rather than an
--- absolute line number — node buffers now carry a frontmatter block on top
--- (tech-spec §8.3), so the body no longer starts at line 1.
function M.find(buf, pattern)
for i, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
local s = line:find(pattern)
if s then
return i, s - 1
end
end
end
function M.set_lines(buf, lines)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
end

View file

@ -15,7 +15,8 @@ describe("promote context item", function()
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 lnum = h.find(buf, "call plumber") -- the context-item line, below frontmatter
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
local task = require("heph.task").promote_under_cursor({ attention = "orange" })
assert.is_truthy(task.node_id)
@ -32,7 +33,7 @@ describe("promote context item", function()
-- 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])
assert.is_truthy(h.find(reloaded, "%- %[%[call plumber%]%]"), "rewritten link line missing from buffer")
-- ...and the container backlinks the task.
local linked = false