diff --git a/crates/heph-core/src/frontmatter.rs b/crates/heph-core/src/frontmatter.rs new file mode 100644 index 0000000..b437b58 --- /dev/null +++ b/crates/heph-core/src/frontmatter.rs @@ -0,0 +1,92 @@ +//! YAML frontmatter is a **projection** (tech-spec §8.3): the daemon renders an +//! editable block on read; heph-core **strips** it on write so it never enters +//! the stored body or the text CRDT. This module owns only the strip side (the +//! render side needs timezone-aware date formatting and lives in `hephd`). +//! +//! Stripping is **conservative**: it removes only a leading, well-formed +//! `---`-delimited block whose first line is a YAML key, so a leading `---` +//! thematic break in ordinary prose is left intact. It is idempotent — a body +//! that has already been stripped is returned unchanged — which keeps the +//! read→write round-trip a no-op even when a client echoes the rendered +//! frontmatter straight back. + +/// Return `body` without a leading YAML frontmatter block. If `body` has no +/// conforming frontmatter, it is returned unchanged (borrowed). +pub fn strip(body: &str) -> &str { + // Must open with the fence on its own first line. + let Some(rest) = body.strip_prefix("---\n") else { + return body; + }; + // The first line inside must look like a YAML key (`key:` / `key: value`). + // A markdown thematic break (`---` then a blank line / heading / prose) + // never does, so this rejects hrules. + if !looks_like_yaml_key(rest.lines().next().unwrap_or("")) { + return body; + } + // Find the closing fence — a line that is exactly `---` — and return what + // follows it. No closing fence ⇒ not frontmatter; leave the body untouched. + let mut offset = 0; + for line in rest.split_inclusive('\n') { + if line.strip_suffix('\n').unwrap_or(line) == "---" { + return &rest[offset + line.len()..]; + } + offset += line.len(); + } + body +} + +/// Does `line` begin with a bare YAML key followed by a colon +/// (`[A-Za-z0-9_-]+:`)? Used to distinguish frontmatter from an hrule. +fn looks_like_yaml_key(line: &str) -> bool { + let b = line.as_bytes(); + let mut i = 0; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'-') { + i += 1; + } + i > 0 && b.get(i) == Some(&b':') +} + +#[cfg(test)] +mod tests { + use super::strip; + + #[test] + fn strips_a_leading_frontmatter_block() { + let body = "---\nid: x\ntitle: Roof\n---\n# Roof\n\nnotes\n"; + assert_eq!(strip(body), "# Roof\n\nnotes\n"); + } + + #[test] + fn empty_body_after_frontmatter() { + assert_eq!(strip("---\nid: x\n---\n"), ""); + } + + #[test] + fn idempotent_when_already_stripped() { + let body = "# Roof\n\nnotes\n"; + assert_eq!(strip(body), body); + assert_eq!(strip(strip(body)), body); + } + + #[test] + fn leaves_a_leading_thematic_break_intact() { + // A real markdown hrule: `---` then a heading, not `key:` lines. + let body = "---\n\n# Heading\n\ntext\n"; + assert_eq!(strip(body), body); + // …and a `---` separating prose later in the doc is never touched. + let mid = "para one\n\n---\n\npara two\n"; + assert_eq!(strip(mid), mid); + } + + #[test] + fn unterminated_block_is_not_frontmatter() { + let body = "---\nid: x\nno closing fence here\n"; + assert_eq!(strip(body), body); + } + + #[test] + fn only_the_first_block_is_removed() { + let body = "---\nid: x\n---\nbody\n\n---\n\nmore\n"; + assert_eq!(strip(body), "body\n\n---\n\nmore\n"); + } +} diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 73978e0..98d4a8c 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod error; pub mod export; pub mod extract; pub mod filter; +pub mod frontmatter; pub mod hlc; pub mod model; pub mod oplog; diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 3378c63..9dbd5db 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -294,6 +294,9 @@ pub(super) fn update( if let Some(t) = title { node.title = t; } + // Frontmatter is a read-only projection (§8.3): strip any leading block a + // client echoes back so it never enters the stored body or the text CRDT. + let body = body.map(|b| crate::frontmatter::strip(&b).to_string()); let body_changed = match body { Some(b) => { let changed = node.body.as_deref() != Some(b.as_str()); diff --git a/crates/hephd/src/datespec.rs b/crates/hephd/src/datespec.rs index 1ffadfe..71d23e6 100644 --- a/crates/hephd/src/datespec.rs +++ b/crates/hephd/src/datespec.rs @@ -62,6 +62,15 @@ pub fn to_epoch_ms(date: NaiveDate) -> i64 { } } +/// Absolute `YYYY-MM-DD` for an epoch-ms date (local). The round-trippable form +/// for the frontmatter edit surface (§8.3) — `parse_date` reads it straight back. +pub fn fmt_iso(ms: i64) -> String { + match Local.timestamp_millis_opt(ms).earliest() { + Some(dt) => dt.date_naive().format("%Y-%m-%d").to_string(), + None => ms.to_string(), + } +} + /// Compact display of an epoch-ms date: `MM-DD` within the current year, /// `YYYY-MM-DD` otherwise. pub fn fmt_date(ms: i64) -> String { diff --git a/crates/hephd/src/frontmatter.rs b/crates/hephd/src/frontmatter.rs new file mode 100644 index 0000000..de77357 --- /dev/null +++ b/crates/hephd/src/frontmatter.rs @@ -0,0 +1,138 @@ +//! Render the editable YAML frontmatter projection for a node (tech-spec §8.3). +//! +//! This is the **read** side: the daemon prepends this block to a node's body +//! when a client requests `node.get {frontmatter: true}`. `heph-core` strips it +//! back off on write, so it never persists. Lives in `hephd` (not `heph-core`) +//! because formatting `do_date`/`late_on` for humans needs the local timezone +//! (via [`crate::datespec`]). +//! +//! Schema (a curated, *editable* subset — not the full export snapshot): +//! `id`/`kind` are read-only; `title` and `tags` edit the node itself; when the +//! node **is** a task or backs one (its canonical-context doc), the task's +//! scalars (`state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`) are +//! rendered and a read-only `task:` id is included so the editor knows where to +//! route those edits. + +use std::fmt::Write as _; + +use heph_core::{Node, Task}; + +use crate::datespec; + +/// Render the frontmatter block (with the `---` fences and a trailing newline) +/// for `node`. `task` is the node's own task or — for a task's +/// canonical-context doc — its owning task. `project_name`/`tags` are resolved +/// by the caller. +pub fn render( + node: &Node, + task: Option<&Task>, + project_name: Option<&str>, + tags: &[String], +) -> String { + let mut fm = String::new(); + let _ = writeln!(fm, "---"); + let _ = writeln!(fm, "id: {}", node.id); + let _ = writeln!(fm, "kind: {}", node.kind.as_str()); + let _ = writeln!(fm, "title: {}", scalar(&node.title)); + let _ = writeln!(fm, "tags: {}", flow_seq(tags)); + + if let Some(t) = task { + let _ = writeln!(fm, "task: {}", t.node_id); + let _ = writeln!(fm, "state: {}", t.state.as_str()); + if let Some(a) = t.attention { + let _ = writeln!(fm, "attention: {}", a.as_str()); + } + if let Some(d) = t.do_date { + let _ = writeln!(fm, "do_date: {}", datespec::fmt_iso(d)); + } + if let Some(l) = t.late_on { + let _ = writeln!(fm, "late_on: {}", datespec::fmt_iso(l)); + } + if let Some(r) = &t.recurrence { + let _ = writeln!(fm, "recurrence: {}", scalar(r)); + } + if let Some(p) = project_name { + let _ = writeln!(fm, "project: {}", scalar(p)); + } + } + + let _ = writeln!(fm, "---"); + fm +} + +/// A YAML scalar, quoted only when bare would be ambiguous (empty, surrounding +/// whitespace, or a character that would break a bare flow scalar). +fn scalar(s: &str) -> String { + let needs_quote = s.is_empty() + || s != s.trim() + || s.starts_with([ + '#', '-', '[', ']', '{', '}', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`', + ]) + || s.contains([':', ',', '\n']); + if needs_quote { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + s.to_string() + } +} + +/// A YAML flow sequence: `[a, b]`, each element scalar-quoted as needed; `[]` +/// when empty. +fn flow_seq(items: &[String]) -> String { + let inner: Vec = items.iter().map(|s| scalar(s)).collect(); + format!("[{}]", inner.join(", ")) +} + +#[cfg(test)] +mod tests { + use super::*; + use heph_core::{Attention, NodeKind, TaskState}; + + fn doc(title: &str) -> Node { + Node { + id: "doc1".into(), + owner_id: "u".into(), + kind: NodeKind::Doc, + title: title.into(), + body: Some(String::new()), + created_at: 0, + modified_at: 0, + hlc: "0".into(), + tombstoned: false, + } + } + + #[test] + fn renders_a_plain_doc_block() { + let fm = render(&doc("Roof"), None, None, &["house".into(), "urgent".into()]); + assert_eq!( + fm, + "---\nid: doc1\nkind: doc\ntitle: Roof\ntags: [house, urgent]\n---\n" + ); + } + + #[test] + fn renders_task_scalars_and_project_when_a_task_is_present() { + let task = Task { + node_id: "task1".into(), + attention: Some(Attention::Red), + do_date: None, + late_on: None, + state: TaskState::Outstanding, + recurrence: None, + }; + let fm = render(&doc("Fix roof"), Some(&task), Some("Camano"), &[]); + assert!(fm.contains("task: task1")); + assert!(fm.contains("state: outstanding")); + assert!(fm.contains("attention: red")); + assert!(fm.contains("project: Camano")); + assert!(fm.contains("tags: []")); + } + + #[test] + fn quotes_titles_that_need_it() { + assert_eq!(scalar("plain"), "plain"); + assert_eq!(scalar("has: colon"), "\"has: colon\""); + assert_eq!(scalar(""), "\"\""); + } +} diff --git a/crates/hephd/src/lib.rs b/crates/hephd/src/lib.rs index d4521b1..7408103 100644 --- a/crates/hephd/src/lib.rs +++ b/crates/hephd/src/lib.rs @@ -11,6 +11,7 @@ pub mod auth; pub mod client; pub mod clock; pub mod datespec; +pub mod frontmatter; pub mod lock; pub mod oauth; pub mod remote; diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 9dbf024..d2613de 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use heph_core::{ - Attention, LinkType, ListFilter, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState, + Attention, LinkType, ListFilter, NewNode, NewTask, Node, NodeKind, SchedulePatch, Store, Task, + TaskState, }; /// A JSON-RPC request line. @@ -111,6 +112,14 @@ struct IdParam { id: String, } +#[derive(Deserialize)] +struct GetNodeParams { + id: String, + /// Prepend the editable YAML frontmatter projection to the body (§8.3). + #[serde(default)] + frontmatter: bool, +} + #[derive(Deserialize)] struct ResolveParams { title: String, @@ -237,13 +246,54 @@ const DEFAULT_LIMIT: usize = 5; /// Default `log.tail` size. const DEFAULT_TAIL: usize = 10; +/// The task whose frontmatter `node` carries: the node itself if it's a task, +/// else the task whose canonical-context doc this node is (§8.3). `None` for a +/// standalone doc/journal. +fn subject_task(store: &dyn Store, node: &Node) -> Result, RpcError> { + if node.kind == NodeKind::Task { + return Ok(store.get_task(&node.id)?); + } + for link in store.backlinks(&node.id)? { + if link.link_type == LinkType::CanonicalContext { + return Ok(store.get_task(&link.src_id)?); + } + } + Ok(None) +} + +/// The name of the project a task is filed under (its `in-project` link), if any. +fn project_name_of(store: &dyn Store, task_id: &str) -> Result, RpcError> { + for link in store.outgoing_links(task_id)? { + if link.link_type == LinkType::InProject { + return Ok(store.get_node(&link.dst_id)?.map(|n| n.title)); + } + } + Ok(None) +} + /// Dispatch one method call against `store`. Synchronous — the transport runs /// this on a blocking pool. pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { Ok(match method { "node.get" => { - let p: IdParam = parse(params)?; - json!(store.get_node(&p.id)?) + let p: GetNodeParams = parse(params)?; + match store.get_node(&p.id)? { + Some(mut node) if p.frontmatter => { + // Render the editable frontmatter projection (§8.3) from the + // node's own fields + its (owning) task, and prepend it. + let task = subject_task(store, &node)?; + let tags = store.tags_of(&node.id)?; + let project = match &task { + Some(t) => project_name_of(store, &t.node_id)?, + None => None, + }; + let fm = + crate::frontmatter::render(&node, task.as_ref(), project.as_deref(), &tags); + node.body = Some(format!("{fm}{}", node.body.as_deref().unwrap_or(""))); + json!(node) + } + other => json!(other), + } } "node.create" => { let p: NewNode = parse(params)?; diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 687f3ff..c889cb5 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -267,6 +267,93 @@ fn tag_add_list_remove_over_socket() { ); } +#[test] +fn frontmatter_renders_on_read_and_is_stripped_on_write() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let doc = c + .call( + "node.create", + json!({ "kind": "doc", "title": "Roof", "body": "# Roof\n\nnotes\n" }), + ) + .unwrap(); + let id = doc["id"].as_str().unwrap().to_string(); + c.call("tag.add", json!({ "node_id": id, "tag": "house" })) + .unwrap(); + + // Read with frontmatter: a leading `---` block carries id/kind/title/tags, + // and the original body follows it. + let got = c + .call("node.get", json!({ "id": id, "frontmatter": true })) + .unwrap(); + let body = got["body"].as_str().unwrap(); + assert!(body.starts_with("---\n"), "no frontmatter fence:\n{body}"); + assert!(body.contains(&format!("id: {id}")), "no id:\n{body}"); + assert!(body.contains("title: Roof"), "no title:\n{body}"); + assert!(body.contains("tags: [house]"), "no tags:\n{body}"); + assert!( + body.contains("# Roof\n\nnotes\n"), + "original body missing:\n{body}" + ); + + // Echo that whole buffer back: the frontmatter is stripped, body unchanged. + c.call("node.update", json!({ "id": id, "body": body })) + .unwrap(); + let plain = c.call("node.get", json!({ "id": id })).unwrap(); + assert_eq!( + plain["body"], "# Roof\n\nnotes\n", + "round-trip altered the body" + ); +} + +#[test] +fn task_context_doc_frontmatter_carries_the_owning_tasks_scalars() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let proj = c + .call( + "node.create", + json!({ "kind": "project", "title": "Camano" }), + ) + .unwrap(); + let pid = proj["id"].as_str().unwrap().to_string(); + let task = c + .call( + "task.create", + json!({ "title": "Fix roof", "attention": "red", "project_id": pid, "do_date": 1_704_067_200_000_i64 }), + ) + .unwrap(); + let task_id = task["node_id"].as_str().unwrap().to_string(); + + // The task's canonical-context doc. + let links = c.call("links.outgoing", json!({ "id": task_id })).unwrap(); + let ctx = links + .as_array() + .unwrap() + .iter() + .find(|l| l["link_type"] == "canonical-context") + .unwrap()["dst_id"] + .as_str() + .unwrap() + .to_string(); + + // Its frontmatter surfaces the owning task's scalars + a `task:` ref. + let got = c + .call("node.get", json!({ "id": ctx, "frontmatter": true })) + .unwrap(); + let body = got["body"].as_str().unwrap(); + assert!( + body.contains(&format!("task: {task_id}")), + "no task ref:\n{body}" + ); + assert!(body.contains("state: outstanding"), "no state:\n{body}"); + assert!(body.contains("attention: red"), "no attention:\n{body}"); + assert!(body.contains("project: Camano"), "no project:\n{body}"); + assert!(body.contains("do_date:"), "no do_date:\n{body}"); +} + #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 1dfe9a2..48323c9 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,4 +28,5 @@ 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; `` still jumps to a task's context doc. +- 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. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 0c535f8..7fe03b9 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -300,14 +300,30 @@ 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 (planned) +## 8.3 Frontmatter as an edit surface (backend built; nvim diff layer next) -> **Status: planned** (§14 roadmap, 2026-06-03). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) should be visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body. +> **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. -The resolving principle is a **two-layer split** that keeps `heph-core` safe against *any* client while making `heph.nvim` a rich editor: +The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor: -- **`heph-core` is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. A pure `frontmatter` module (sibling to `extract.rs`) provides `render(node, task?, project, tags)` (prepended by `get_node` and friends) and `strip(body) → body_without` (applied by `update_node` **before** the `yrs` CRDT diff). 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 core 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. +- ✅ **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. + +**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: + +```yaml +--- +id: 01J… # read-only +kind: doc # read-only +title: Fix the roof # editable → rename +tags: [house, roof] # editable → tag.add / tag.remove +task: 01J… # present iff this node is/backs a task (read-only ref) +state: outstanding # editable (a mistyped status is a validation error — no picker) +attention: red # editable → task.set_attention +do_date: 2026-06-10 # editable → task.set_schedule (YYYY-MM-DD, local) +project: Camano # editable → task.set_project (by name) +--- +``` Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, `do_date`, `late_on`, `recurrence`, `project`, `tags`, and `state` are editable. **`state` is editable but has no picker or hint** (to keep the UI simple) — a mistyped status value returns a **validation error** rather than guessing. Frontmatter is rendered for any editable-body node. @@ -452,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 `↻`); `` 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::`, 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) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §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. 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]]).