heph CLI + export
Some checks failed
Build / validate (pull_request) Failing after 2s

Slice 7 (tech-spec §1, §5, §9).

- Export (heph-core): render each non-tombstoned node to `<kind>/<id>.md`
  with YAML frontmatter (id, kind, title, timestamps, task scalars,
  aliases, outgoing links) + body. One-way snapshot; `Store::export`
  writes the tree; tombstones excluded. Added `export` RPC method and
  Error::Io.
- `heph` CLI (clap): thin client of hephd over the socket — `next`
  (concise ranked rows), `task`, `doc`, `get`, `export`. Never touches
  SQLite directly.

Tests: 3 export render unit + 2 export round-trip integration + 3 CLI
process tests driving the real `heph` binary against a real daemon
(task→next, empty-store message, export writes files). 70 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-05-31 20:33:59 -07:00
commit 739214bd07
17 changed files with 625 additions and 2 deletions

View file

@ -48,7 +48,7 @@ A Cargo workspace (`Cargo.toml` at the root) plus the Neovim plugin and repo too
./crates/heph-core/ # core lib: data model, Store trait + SQLite store, extraction,
# recurrence, "what is next?" ranking, op-log/HLC/CRDT sync
./crates/hephd/ # daemon: local mode done (JSON-RPC over unix socket + file lock); server/client modes planned
./crates/heph/ # CLI (planned): export, scripting, `heph conflicts`
./crates/heph/ # CLI: next/task/doc/get/export (thin client of hephd); `heph conflicts` planned
./heph.nvim/ # Neovim plugin (planned): primary surface; replaces obsidian.nvim
./docs/ # Diataxis docs (incl. [[design]] + [[tech-spec]]), Quartz config, release content
./docs/changelog.d/ # towncrier fragments for noteworthy changes

14
Cargo.lock generated
View file

@ -340,6 +340,19 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heph"
version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"heph-core",
"hephd",
"serde_json",
"tempfile",
"tokio",
]
[[package]]
name = "heph-core"
version = "0.0.0"
@ -350,6 +363,7 @@ dependencies = [
"rrule",
"rusqlite",
"serde",
"tempfile",
"thiserror 2.0.18",
"ulid",
]

View file

@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/heph-core", "crates/hephd"]
members = ["crates/heph-core", "crates/hephd", "crates/heph"]
[workspace.package]
edition = "2021"

View file

@ -19,3 +19,4 @@ serde.workspace = true
[dev-dependencies]
proptest = "1"
tempfile = "3"

View file

@ -7,6 +7,10 @@ pub enum Error {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
/// A filesystem failure (e.g. during `export`).
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// The DB file is already locked by another `local`/`server` process.
#[error("store is already locked by another process: {0}")]
Locked(String),

View file

@ -0,0 +1,185 @@
//! Export — materialize the store as a directory tree of `.md` files
//! (tech-spec §5).
//!
//! A faithful, **one-way** portable snapshot: each non-tombstoned node becomes
//! `<kind>/<id>.md` with YAML frontmatter (id, kind, title, timestamps, task
//! scalars, aliases, outgoing links) plus its markdown body. There is no import
//! in v1 — SQLite remains the source of truth.
use std::fmt::Write as _;
use crate::model::{Link, Node, Task};
/// One file to write during export.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportFile {
/// Path relative to the export root (e.g. `task/01ARZ….md`).
pub path: String,
/// Full file contents (frontmatter + body).
pub content: String,
}
/// Everything needed to render one node's export file.
pub struct NodeExport<'a> {
/// The node itself.
pub node: &'a Node,
/// Its task scalars, if it is a task.
pub task: Option<&'a Task>,
/// Its aliases (wiki-link names), if any.
pub aliases: &'a [String],
/// Its non-tombstoned outgoing links.
pub links: &'a [Link],
}
/// The relative path for a node's export file: `<kind>/<id>.md`.
pub fn file_path(node: &Node) -> String {
format!("{}/{}.md", node.kind.as_str(), node.id)
}
/// Render a node to its export file (frontmatter + body).
pub fn render(export: &NodeExport) -> ExportFile {
let n = export.node;
let mut fm = String::new();
let _ = writeln!(fm, "---");
let _ = writeln!(fm, "id: {}", n.id);
let _ = writeln!(fm, "kind: {}", n.kind.as_str());
let _ = writeln!(fm, "title: {}", yaml_string(&n.title));
let _ = writeln!(fm, "created_at: {}", n.created_at);
let _ = writeln!(fm, "modified_at: {}", n.modified_at);
if let Some(task) = export.task {
let _ = writeln!(fm, "state: {}", task.state.as_str());
if let Some(a) = task.attention {
let _ = writeln!(fm, "attention: {}", a.as_str());
}
if let Some(d) = task.do_date {
let _ = writeln!(fm, "do_date: {d}");
}
if let Some(l) = task.late_on {
let _ = writeln!(fm, "late_on: {l}");
}
if let Some(r) = &task.recurrence {
let _ = writeln!(fm, "recurrence: {}", yaml_string(r));
}
}
if !export.aliases.is_empty() {
let _ = writeln!(fm, "aliases:");
for alias in export.aliases {
let _ = writeln!(fm, " - {}", yaml_string(alias));
}
}
if !export.links.is_empty() {
let _ = writeln!(fm, "links:");
for link in export.links {
let _ = writeln!(
fm,
" - {{ type: {}, dst: {} }}",
link.link_type.as_str(),
link.dst_id
);
}
}
let _ = writeln!(fm, "---");
let body = n.body.as_deref().unwrap_or("");
let mut content = fm;
content.push_str(body);
if !content.ends_with('\n') {
content.push('\n');
}
ExportFile {
path: file_path(n),
content,
}
}
/// Quote a scalar for YAML when needed; always quoting is safe and simplest.
fn yaml_string(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Attention, LinkType, NodeKind, TaskState};
fn node(kind: NodeKind, id: &str, title: &str, body: Option<&str>) -> Node {
Node {
id: id.into(),
owner_id: "u".into(),
kind,
title: title.into(),
body: body.map(str::to_string),
created_at: 100,
modified_at: 200,
hlc: "0".into(),
tombstoned: false,
}
}
#[test]
fn doc_renders_frontmatter_and_body() {
let n = node(NodeKind::Doc, "D1", "Roof log", Some("# Roof\n\nnotes"));
let f = render(&NodeExport {
node: &n,
task: None,
aliases: &[],
links: &[],
});
assert_eq!(f.path, "doc/D1.md");
assert!(f.content.starts_with("---\nid: D1\nkind: doc\n"));
assert!(f.content.contains("title: \"Roof log\"\n"));
assert!(f.content.ends_with("# Roof\n\nnotes\n"));
}
#[test]
fn task_renders_scalars_and_links() {
let n = node(NodeKind::Task, "T1", "Fix roof", None);
let task = Task {
node_id: "T1".into(),
attention: Some(Attention::Orange),
do_date: Some(555),
late_on: None,
state: TaskState::Outstanding,
recurrence: Some("FREQ=DAILY".into()),
};
let links = vec![Link {
id: "L1".into(),
src_id: "T1".into(),
dst_id: "D9".into(),
link_type: LinkType::CanonicalContext,
created_at: 1,
tombstoned: false,
}];
let f = render(&NodeExport {
node: &n,
task: Some(&task),
aliases: &[],
links: &links,
});
assert!(f.content.contains("state: outstanding\n"));
assert!(f.content.contains("attention: orange\n"));
assert!(f.content.contains("do_date: 555\n"));
assert!(!f.content.contains("late_on"));
assert!(f.content.contains("recurrence: \"FREQ=DAILY\"\n"));
assert!(f
.content
.contains("- { type: canonical-context, dst: D9 }\n"));
}
#[test]
fn title_quotes_are_escaped() {
let n = node(NodeKind::Doc, "D2", "She said \"hi\"", Some(""));
let f = render(&NodeExport {
node: &n,
task: None,
aliases: &[],
links: &[],
});
assert!(f.content.contains(r#"title: "She said \"hi\"""#));
}
}

View file

@ -10,6 +10,7 @@
pub mod clock;
pub mod error;
pub mod export;
pub mod extract;
pub mod model;
pub mod ranking;
@ -19,6 +20,7 @@ pub mod store;
pub use clock::{Clock, FixedClock};
pub use error::{Error, Result};
pub use export::{render as render_export, ExportFile, NodeExport};
pub use extract::{extract, ContextItem, Extraction};
pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState};
pub use ranking::{rank, Dimension, RankedTask, RANKING};

View file

@ -0,0 +1,51 @@
//! `export` — write the store to a directory tree of `.md` files (tech-spec §5).
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use super::{links, nodes, tasks};
use crate::error::Result;
use crate::export::{render, NodeExport};
use crate::model::NodeKind;
/// Materialize every non-tombstoned node for `owner` under `dir`, returning the
/// count written. One-way snapshot; SQLite stays the source of truth.
pub(super) fn export(conn: &Connection, owner: &str, dir: &Path) -> Result<usize> {
let ids: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT id FROM nodes WHERE owner_id = ?1 AND tombstoned = 0 ORDER BY created_at, id",
)?;
let rows = stmt.query_map([owner], |r| r.get(0))?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let mut count = 0;
for id in ids {
let Some(node) = nodes::get(conn, &id)? else {
continue;
};
let task = if node.kind == NodeKind::Task {
tasks::get(conn, &id)?
} else {
None
};
let aliases = nodes::aliases(conn, &id)?;
let outgoing = links::outgoing(conn, &id)?;
let file = render(&NodeExport {
node: &node,
task: task.as_ref(),
aliases: &aliases,
links: &outgoing,
});
let path = dir.join(&file.path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, file.content)?;
count += 1;
}
Ok(count)
}

View file

@ -9,6 +9,7 @@
//! as free functions over a `&Connection`; the [`Store`] impl here is a thin
//! delegating layer so a transaction can span several of them.
mod exporter;
mod links;
mod log;
mod migrations;
@ -177,6 +178,10 @@ impl Store for LocalStore {
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>> {
log::tail(&self.conn, task_id, n)
}
fn export(&self, dir: &std::path::Path) -> Result<usize> {
exporter::export(&self.conn, &self.owner_id, dir)
}
}
#[cfg(test)]

View file

@ -136,6 +136,13 @@ pub(super) fn update(
Ok(node)
}
/// A node's aliases (wiki-link names), sorted. Empty until aliases are written.
pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> {
let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?;
let rows = stmt.query_map([id], |r| r.get(0))?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Tombstone (soft-delete) a node. No hard deletes — tombstones keep merge
/// monotonic (tech-spec §4.3).
pub(super) fn tombstone(conn: &Connection, now: i64, id: &str) -> Result<()> {

View file

@ -82,4 +82,8 @@ pub trait Store {
/// The task's latest `n` log entries (oldest→newest); empty if it has none.
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>>;
/// Export every non-tombstoned node to a `.md` directory tree under `dir`,
/// returning the count written (tech-spec §5). One-way; no import.
fn export(&self, dir: &std::path::Path) -> Result<usize>;
}

View file

@ -0,0 +1,58 @@
//! `Store::export` writes a faithful .md tree (tech-spec §5, slice 7).
use heph_core::{FixedClock, LocalStore, NewNode, NewTask, Store};
fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap()
}
#[test]
fn export_writes_a_file_per_node_with_frontmatter_and_body() {
let dir = tempfile::tempdir().unwrap();
let mut s = store();
let doc = s
.create_node(NewNode::doc("Roof log", "# Roof\n\nCalled contractor."))
.unwrap();
let task = s
.create_task(NewTask {
title: "Fix roof".into(),
..Default::default()
})
.unwrap();
// task.create also makes a canonical context doc → 3 nodes total.
let count = s.export(dir.path()).unwrap();
assert_eq!(count, 3);
// The doc file exists with frontmatter and its body.
let doc_file = dir.path().join(format!("doc/{}.md", doc.id));
let doc_text = std::fs::read_to_string(&doc_file).unwrap();
assert!(doc_text.starts_with("---\n"));
assert!(doc_text.contains(&format!("id: {}\n", doc.id)));
assert!(doc_text.contains("kind: doc\n"));
assert!(doc_text.contains("title: \"Roof log\"\n"));
assert!(doc_text.ends_with("# Roof\n\nCalled contractor.\n"));
// The task file carries its scalars and the canonical-context link.
let task_file = dir.path().join(format!("task/{}.md", task.node_id));
let task_text = std::fs::read_to_string(&task_file).unwrap();
assert!(task_text.contains("kind: task\n"));
assert!(task_text.contains("state: outstanding\n"));
assert!(task_text.contains("type: canonical-context"));
}
#[test]
fn export_excludes_tombstoned_nodes() {
let dir = tempfile::tempdir().unwrap();
let mut s = store();
let keep = s.create_node(NewNode::doc("Keep", "kept")).unwrap();
let gone = s.create_node(NewNode::doc("Gone", "gone")).unwrap();
s.tombstone_node(&gone.id).unwrap();
let count = s.export(dir.path()).unwrap();
assert_eq!(count, 1);
assert!(dir.path().join(format!("doc/{}.md", keep.id)).exists());
assert!(!dir.path().join(format!("doc/{}.md", gone.id)).exists());
}

24
crates/heph/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "heph"
description = "Hephaestus CLI: a thin client of the local hephd daemon (utility/admin surface)."
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
authors.workspace = true
rust-version.workspace = true
[[bin]]
name = "heph"
path = "src/main.rs"
[dependencies]
heph-core = { path = "../heph-core" }
hephd = { path = "../hephd" }
clap.workspace = true
serde_json.workspace = true
anyhow.workspace = true
[dev-dependencies]
tempfile = "3"
tokio.workspace = true

158
crates/heph/src/main.rs Normal file
View file

@ -0,0 +1,158 @@
//! `heph` — the CLI surface (tech-spec §1). A thin client of the local
//! `hephd`: it never touches SQLite, only the daemon socket. Secondary to
//! `heph.nvim`; for scripting, admin, smoke tests, and `export`.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use heph_core::{Node, RankedTask, Task};
use hephd::{default_socket_path, Client};
#[derive(Parser, Debug)]
#[command(name = "heph", version, about)]
struct Cli {
/// Path to the hephd unix socket (defaults to the standard runtime path).
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Show the Tactical "what is next?" ranking.
Next {
/// Restrict to a project node id.
#[arg(long)]
scope: Option<String>,
/// Maximum rows (red items always show).
#[arg(long, default_value_t = 5)]
limit: usize,
},
/// Create a committed task (auto-creates its canonical context doc).
Task {
/// The task title.
title: String,
/// Attention-state: white|orange|red|blue.
#[arg(long)]
attention: Option<String>,
/// Earliest-actionable date, epoch ms.
#[arg(long)]
do_date: Option<i64>,
/// Lateness-problem marker, epoch ms.
#[arg(long)]
late_on: Option<i64>,
/// Project node id to file it under.
#[arg(long)]
project: Option<String>,
/// RFC-5545 RRULE for a recurring task.
#[arg(long)]
recurrence: Option<String>,
},
/// Create a document node.
Doc {
/// The document title.
title: String,
/// Markdown body.
#[arg(long)]
body: Option<String>,
},
/// Fetch a node by id and print it as JSON.
Get {
/// Node id.
id: String,
},
/// Export the store to a directory tree of .md files.
Export {
/// Destination directory (created if needed).
dir: PathBuf,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let socket = cli.socket.unwrap_or_else(default_socket_path);
let mut client = Client::connect(&socket)?;
match cli.command {
Command::Next { scope, limit } => {
let result = client.call("next", json!({ "scope": scope, "limit": limit }))?;
let tasks: Vec<RankedTask> = serde_json::from_value(result)?;
if tasks.is_empty() {
println!("Nothing actionable right now.");
}
for t in &tasks {
println!("{}", format_row(t));
}
}
Command::Task {
title,
attention,
do_date,
late_on,
project,
recurrence,
} => {
let result = client.call(
"task.create",
json!({
"title": title,
"attention": attention,
"do_date": do_date,
"late_on": late_on,
"project_id": project,
"recurrence": recurrence,
}),
)?;
let task: Task = serde_json::from_value(result)?;
println!("Created task {} \"{title}\"", task.node_id);
}
Command::Doc { title, body } => {
let result = client.call(
"node.create",
json!({ "kind": "doc", "title": title, "body": body }),
)?;
let node: Node = serde_json::from_value(result)?;
println!("Created doc {} \"{}\"", node.id, node.title);
}
Command::Get { id } => {
let result = client.call("node.get", json!({ "id": id }))?;
println!("{}", serde_json::to_string_pretty(&result)?);
}
Command::Export { dir } => {
let path = dir
.to_str()
.context("export path is not valid UTF-8")?
.to_string();
let result = client.call("export", json!({ "path": path }))?;
let count = result.get("count").and_then(Value::as_u64).unwrap_or(0);
println!("Exported {count} nodes to {}", dir.display());
}
}
Ok(())
}
/// One concise Tactical row: attention tag, title, and do/late context.
fn format_row(t: &RankedTask) -> String {
let tag = t
.attention
.map(|a| format!("[{}]", serde_json::to_value(a).unwrap().as_str().unwrap()))
.unwrap_or_else(|| "[ ]".to_string());
let mut extra = Vec::new();
if let Some(d) = t.do_date {
extra.push(format!("do:{d}"));
}
if let Some(l) = t.late_on {
extra.push(format!("late:{l}"));
}
let suffix = if extra.is_empty() {
String::new()
} else {
format!(" ({})", extra.join(", "))
};
format!("{tag} {}{suffix}", t.title)
}

97
crates/heph/tests/cli.rs Normal file
View file

@ -0,0 +1,97 @@
//! CLI tests (tech-spec §9): run the real `heph` binary against a real `hephd`
//! over a unix socket, and assert output + side effects (export files).
use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::Duration;
use tokio::net::UnixListener;
use heph_core::{FixedClock, LocalStore};
use hephd::Daemon;
const NOW: i64 = 1_704_067_200_000;
/// Spawn a daemon on its own thread+runtime; return (socket, tempdir).
fn spawn_daemon() -> (PathBuf, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("heph.db");
let socket = dir.path().join("d.sock");
let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap();
let socket_for_thread = socket.clone();
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let listener = UnixListener::bind(&socket_for_thread).unwrap();
let _ = Daemon::new(store).serve(listener).await;
});
});
for _ in 0..200 {
if socket.exists() {
break;
}
thread::sleep(Duration::from_millis(5));
}
(socket, dir)
}
/// Run the `heph` binary against `socket`; return (stdout, success).
fn heph(socket: &std::path::Path, args: &[&str]) -> (String, bool) {
let out = Command::new(env!("CARGO_BIN_EXE_heph"))
.arg("--socket")
.arg(socket)
.args(args)
.output()
.expect("run heph");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
out.status.success(),
)
}
#[test]
fn task_then_next_shows_the_task() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["task", "Buy milk", "--attention", "red"]);
assert!(ok, "task create failed: {out}");
assert!(out.contains("Created task"), "{out}");
let (out, ok) = heph(&socket, &["next"]);
assert!(ok);
assert!(out.contains("[red]"), "{out}");
assert!(out.contains("Buy milk"), "{out}");
}
#[test]
fn next_on_empty_store_is_friendly() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["next"]);
assert!(ok);
assert!(out.contains("Nothing actionable"), "{out}");
}
#[test]
fn export_writes_markdown_files() {
let (socket, dir) = spawn_daemon();
heph(&socket, &["doc", "Roof log", "--body", "# Roof"]);
let export_dir = dir.path().join("export");
let (out, ok) = heph(&socket, &["export", export_dir.to_str().unwrap()]);
assert!(ok, "export failed: {out}");
assert!(out.contains("Exported 1 nodes"), "{out}");
// The doc landed as a .md file under doc/.
let docs: Vec<_> = std::fs::read_dir(export_dir.join("doc"))
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(docs.len(), 1);
let text = std::fs::read_to_string(docs[0].path()).unwrap();
assert!(text.contains("title: \"Roof log\""), "{text}");
}

View file

@ -7,6 +7,8 @@
//! **mode-agnostic**: Tactical/Strategic/Organizational are plugin-side
//! compositions of these primitives, not daemon concepts.
use std::path::Path;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@ -154,6 +156,11 @@ struct LogTailParams {
n: Option<usize>,
}
#[derive(Deserialize)]
struct ExportParams {
path: String,
}
/// Default `next`/`list` result size (tech-spec §6).
const DEFAULT_LIMIT: usize = 5;
/// Default `log.tail` size.
@ -217,6 +224,11 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: LogTailParams = parse(params)?;
json!(store.log_tail(&p.task_id, p.n.unwrap_or(DEFAULT_TAIL))?)
}
"export" => {
let p: ExportParams = parse(params)?;
let count = store.export(Path::new(&p.path))?;
json!({ "count": count })
}
other => {
return Err(RpcError::new(
METHOD_NOT_FOUND,

View file

@ -6,4 +6,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite.
- Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging.
- `hephd` daemon, local mode (§3, §6): exclusive file lock (handoff-ready), line-delimited JSON-RPC over a unix socket exposing the node/task/next/links/log methods, with DB work on tokio's blocking pool. Synchronous client for surfaces/CLI. Model types are serde-serializable.
- `heph` CLI (§1) — a thin client of the daemon: `next`, `task`, `doc`, `get`, `export`. Export materializes the store to a `<kind>/<id>.md` tree with YAML frontmatter + body (§5), one-way, tombstones excluded.
- CI runs the Rust suite (fmt/clippy/test) via the project build hook.