generated from eblume/project-template
heph.nvim: context-item promotion + Dagger headless-nvim CI (slice 11c)
Some checks failed
Build / validate (pull_request) Failing after 3s
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:
parent
7c9a734ebd
commit
b97c387252
21 changed files with 435 additions and 10 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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=.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))?)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
46
heph.nvim/tests/e2e/promote_spec.lua
Normal file
46
heph.nvim/tests/e2e/promote_spec.lua
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue