Phase 1: v1 prototype #1

Merged
eblume merged 91 commits from feature/v1-prototype into main 2026-06-03 20:48:23 -07:00
10 changed files with 249 additions and 8 deletions
Showing only changes of commit df7f43788b - Show all commits

feat(core): task.set_project — move-to-project with OR-set link semantics (§8.1)

Add `Store::set_task_project` (heph-core + RemoteStore) and the
`task.set_project` RPC: tombstone the task's existing `in-project` link(s)
and add a new one (or none, to unfile). A given project id must name a
live project-kind node, else InvalidArg/NodeNotFound.

Route `heph edit --project` through it, fixing a duplicate-link bug (the
old path added an in-project link without removing the prior one);
`--project none` now unfiles. Factor a `links::tombstone` helper out of
`sync_wiki_links`.

Tests: core move/unfile/reject + a duplicate-link regression; a socket
dispatch test. The TUI `m` gesture follows in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Erich Blume 2026-06-03 10:35:16 -07:00

View file

@ -70,6 +70,15 @@ pub(super) fn add(
Ok(link)
}
/// Tombstone a single link by id, recording the OR-set `link.remove` op.
/// Monotonic: re-tombstoning an already-dead link is a harmless no-op write.
pub(super) fn tombstone(conn: &Connection, owner: &str, now: i64, link_id: &str) -> Result<()> {
conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?;
let hlc = next_hlc(conn, now)?;
ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?;
Ok(())
}
/// The destination of the first non-tombstoned link of `link_type` out of
/// `src_id`, if any (e.g. a task's canonical-context doc or its log doc).
pub(super) fn first_dst(
@ -144,9 +153,7 @@ pub(super) fn sync_wiki_links(
// Tombstone links whose target is no longer referenced.
for (link_id, dst) in &existing {
if !desired_set.contains(dst) {
conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?;
let hlc = next_hlc(conn, now)?;
ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?;
tombstone(conn, owner, now, link_id)?;
}
}
// Add links for newly-referenced targets.

View file

@ -237,6 +237,11 @@ impl Store for LocalStore {
tasks::set_schedule(&self.conn, &self.owner_id, now, node_id, patch)
}
fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task> {
let now = self.clock.now_ms();
tasks::set_project(&mut self.conn, &self.owner_id, now, node_id, project_id)
}
fn promote(
&mut self,
container_id: &str,

View file

@ -535,6 +535,40 @@ pub(super) fn set_attention(
require(conn, node_id)
}
/// Re-file a task under `project_id`, or unfile it entirely when `None`
/// (tech-spec §8.1 move-to-project). OR-set link semantics: tombstone the
/// task's existing `in-project` links, then add a fresh one if a project is
/// given. A given `project_id` must name a live `project`-kind node. Records
/// only link ops (no task-scalar change), all in one transaction.
pub(super) fn set_project(
conn: &mut Connection,
owner: &str,
now: i64,
node_id: &str,
project_id: Option<&str>,
) -> Result<Task> {
require(conn, node_id)?; // task must exist
if let Some(pid) = project_id {
let project = nodes::get(conn, pid)?.ok_or_else(|| Error::NodeNotFound(pid.into()))?;
if project.tombstoned || project.kind != NodeKind::Project {
return Err(Error::InvalidArg(format!("{pid} is not a project node")));
}
}
let tx = conn.transaction()?;
for link in links::outgoing(&tx, node_id)? {
if link.link_type == LinkType::InProject {
links::tombstone(&tx, owner, now, &link.id)?;
}
}
if let Some(pid) = project_id {
links::add(&tx, owner, now, node_id, pid, LinkType::InProject)?;
}
tx.commit()?;
require(conn, node_id)
}
/// Apply a partial schedule update (do-date / late-on / recurrence) — the
/// "reschedule" path (tech-spec §6). Reads the current row, overlays the
/// present `patch` fields (a double-option per field: absent = leave, `null` =

View file

@ -83,6 +83,12 @@ pub trait Store {
/// set. This is the "reschedule" path (the scalars with no dedicated setter).
fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task>;
/// Re-file a task under a project (or unfile it when `project_id` is
/// `None`) — the move-to-project path (tech-spec §8.1). OR-set link
/// semantics: the old `in-project` link is tombstoned and a new one added.
/// A given `project_id` must name a live `project`-kind node.
fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> 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

View file

@ -213,6 +213,105 @@ fn project_link_is_created_when_given() {
assert!(has_project);
}
#[test]
fn set_project_moves_then_unfiles_and_rejects_non_projects() {
let mut s = store();
let project = |s: &mut LocalStore, title: &str| {
s.create_node(NewNode {
kind: NodeKind::Project,
title: title.into(),
body: None,
})
.unwrap()
.id
};
let chores = project(&mut s, "Chores");
let garden = project(&mut s, "Garden");
let task = s
.create_task(NewTask {
title: "Water the beds".into(),
project_id: Some(chores.clone()),
..Default::default()
})
.unwrap();
let id = task.node_id;
// The active `in-project` destinations of the task.
let projects_of = |s: &mut LocalStore| -> Vec<String> {
s.outgoing_links(&id)
.unwrap()
.into_iter()
.filter(|l| l.link_type == LinkType::InProject)
.map(|l| l.dst_id)
.collect()
};
// Move Chores → Garden: exactly one active link, now to Garden.
s.set_task_project(&id, Some(&garden)).unwrap();
assert_eq!(projects_of(&mut s), vec![garden.clone()]);
// Unfile (None): no active in-project links remain.
s.set_task_project(&id, None).unwrap();
assert!(projects_of(&mut s).is_empty());
// A non-project destination is rejected (and nothing is filed).
let doc = s.create_node(NewNode::doc("Just a note", "")).unwrap();
assert!(s.set_task_project(&id, Some(&doc.id)).is_err());
assert!(projects_of(&mut s).is_empty());
// A missing project id is rejected too.
assert!(s.set_task_project(&id, Some("does-not-exist")).is_err());
}
/// Regression: re-filing a task must never accumulate duplicate `in-project`
/// links. The old CLI `edit --project` path added a link without tombstoning
/// the previous one, so a task could end up filed under two (or N) projects at
/// once. `set_task_project` replaces, so the active count stays exactly one —
/// even when re-filing to the *same* project repeatedly.
#[test]
fn set_project_never_accumulates_duplicate_links() {
let mut s = store();
let project = |s: &mut LocalStore, title: &str| {
s.create_node(NewNode {
kind: NodeKind::Project,
title: title.into(),
body: None,
})
.unwrap()
.id
};
let chores = project(&mut s, "Chores");
let garden = project(&mut s, "Garden");
let id = s
.create_task(NewTask {
title: "Mulch".into(),
project_id: Some(chores.clone()),
..Default::default()
})
.unwrap()
.node_id;
let active_in_project = |s: &mut LocalStore| -> usize {
s.outgoing_links(&id)
.unwrap()
.iter()
.filter(|l| l.link_type == LinkType::InProject)
.count()
};
// Re-file to the same project several times, then to a different one.
for target in [&chores, &chores, &garden, &garden] {
s.set_task_project(&id, Some(target)).unwrap();
assert_eq!(
active_in_project(&mut s),
1,
"exactly one active project link"
);
}
}
#[test]
fn updating_a_body_materializes_resolved_wiki_links() {
let mut s = store();

View file

@ -122,7 +122,7 @@ enum Command {
/// Set attention: white|orange|red|blue.
#[arg(short = 'a', long)]
attention: Option<String>,
/// File under a project (by name; adds an in-project link).
/// Re-file under a project (by name); `none` unfiles the task.
#[arg(long)]
project: Option<String>,
},
@ -473,10 +473,17 @@ fn main() -> Result<()> {
if let Some(a) = attention {
client.call("task.set_attention", json!({ "id": id, "attention": a }))?;
}
if let Some(pid) = resolve_project(&mut client, project.as_deref())? {
if let Some(spec) = project.as_deref() {
// Re-file (or unfile with `none`) via the move-to-project path,
// which tombstones the old `in-project` link rather than piling
// a second one on top of it.
let project_id = match spec {
"none" | "clear" => None,
name => resolve_project(&mut client, Some(name))?,
};
client.call(
"links.add",
json!({ "src": id, "dst": pid, "link_type": "in-project" }),
"task.set_project",
json!({ "id": id, "project_id": project_id }),
)?;
}
println!("Edited task {id}");

View file

@ -167,6 +167,13 @@ impl Store for RemoteStore {
self.call_as("task.set_schedule", params)
}
fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task> {
self.call_as(
"task.set_project",
json!({ "id": node_id, "project_id": project_id }),
)
}
fn promote(
&mut self,
container_id: &str,

View file

@ -143,6 +143,14 @@ struct SetAttentionParams {
attention: Attention,
}
#[derive(Deserialize)]
struct SetProjectParams {
id: String,
/// Target project node id; `null`/absent unfiles the task.
#[serde(default)]
project_id: Option<String>,
}
#[derive(Deserialize)]
struct PromoteParams {
container_id: String,
@ -273,6 +281,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let patch: SchedulePatch = parse(params)?;
json!(store.set_task_schedule(&id.id, patch)?)
}
"task.set_project" => {
let p: SetProjectParams = parse(params)?;
json!(store.set_task_project(&p.id, p.project_id.as_deref())?)
}
"task.skip" => {
let p: IdParam = parse(params)?;
json!(store.skip_recurrence(&p.id)?)

View file

@ -164,6 +164,69 @@ fn task_set_schedule_patches_over_socket() {
assert!(got["recurrence"].is_null());
}
#[test]
fn task_set_project_moves_and_unfiles_over_socket() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let mk_project = |c: &mut Client, title: &str| -> String {
c.call("node.create", json!({ "kind": "project", "title": title }))
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
};
let chores = mk_project(&mut c, "Chores");
let garden = mk_project(&mut c, "Garden");
let task = c
.call(
"task.create",
json!({ "title": "Water the beds", "project_id": chores }),
)
.unwrap();
let id = task["node_id"].as_str().unwrap().to_string();
// Active in-project destinations of the task.
let projects_of = |c: &mut Client| -> Vec<String> {
c.call("links.outgoing", json!({ "id": id }))
.unwrap()
.as_array()
.unwrap()
.iter()
.filter(|l| l["link_type"] == "in-project")
.map(|l| l["dst_id"].as_str().unwrap().to_string())
.collect()
};
// Move Chores → Garden: exactly one active link, to Garden.
c.call(
"task.set_project",
json!({ "id": id, "project_id": garden }),
)
.unwrap();
assert_eq!(projects_of(&mut c), vec![garden.clone()]);
// Unfile (null project): no active in-project links.
c.call("task.set_project", json!({ "id": id, "project_id": null }))
.unwrap();
assert!(projects_of(&mut c).is_empty());
// A non-project destination is rejected.
let doc = c
.call("node.create", json!({ "kind": "doc", "title": "Note" }))
.unwrap();
let doc_id = doc["id"].as_str().unwrap();
assert!(
c.call(
"task.set_project",
json!({ "id": id, "project_id": doc_id })
)
.is_err(),
"filing under a non-project node must error"
);
}
#[test]
fn promote_context_item_over_socket() {
let (socket, _dir) = spawn_daemon();

View file

@ -23,4 +23,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- CLI as a complete task surface (§1, §6.2.1): `heph` now implements the entire daemon API and is the task capture/scripting surface. Structured fields are flags with **human dates** (`--do-date tomorrow|+3d|fri|YYYY-MM-DD`, shown back compactly in `next`/`list`) and **recurrence** (`--recur` presets/natural-language like "every 3 days", or a raw `--rrule`). New verbs: `list`, `done`/`drop`/`skip`, `attention`, `edit` (reschedule do-date/late-on/recurrence, re-attention, re-file — backed by the new `task.set_schedule` RPC), `promote`, `show`, `log` (append or tail), `health`, `node update`/`rm`, `resolve`, `links`/`backlinks`, `link add`, `project add [--parent]`, `sync [--status]`, `conflicts [resolve]`. Projects are referenced by name. Date/recurrence parsing is unit-tested; the new verbs have real-socket process tests.
- Daemon lifecycle is now an explicit OS service, and all surfaces are connect-only (no more auto-spawn). `heph daemon start/stop/restart/status/uninstall` idempotently manages a launchd agent (macOS) or systemd user service (Linux) that runs `hephd` on your default store; `heph.nvim` no longer spawns or supervises a daemon — it just connects and points you at `heph daemon start` if none is running. Rationale: once the CLI became a first-class surface, a daemon owned by one surface couldn't be shared (see [[run-the-daemon]], [[design]] §4).
- Filter views (§8.2) — saved agenda slices, so the agenda isn't one flat list. `heph view <name>` runs a built-in view (`tom` Top of Mind, `ondeck` On Deck, `chores`, `work` Work Tasks, `tasks`) seeded from the owner's Todoist filter queries; `heph view` with no name lists them, and `:Heph view <name>` does the same in Neovim. Under the hood, `list` now takes a `ListFilter` predicate-as-data (attention include/exclude sets, project-subtree scope, project exclusions, an actionable do-date gate), and views resolve project names to ids and expand each to its `parent`-link subtree. The Schedule view is intentionally omitted (time-of-day isn't modeled on date-grained do-dates).
- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim. Re-filing a task to a different project is the one remaining capture gap.
- `heph-tui` (§8.1) — a terminal task agenda/triage UI, the primary surface for working a large task set (the §6.2.1 Todoist study showed triage, not single edits, dominates). A `ratatui` app, thin client of the daemon socket. Three panes: a sidebar of the five filter views + your projects, an attention-colored task list with compact human do/late dates, and a preview of the highlighted task's context doc + recent log. Triage from the keyboard: `a` add (guided title → attention → do-date, filed under the selected project), `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push to On Deck, `e` reschedule the do-date; `o` opens the task's context doc in your nvim (live, via heph.nvim) and returns. `j/k` move, `Tab`/`h`/`l` switch panes, `r` refresh, `q` quit. Run it with `heph-tui` (honors `--socket` / `$HEPH_SOCKET`). `a` is a Todoist-style single-line quick-add: `Buy milk tomorrow p2 #Work every week` parses into title + attention (p1p4) + do-date + recurrence + project (multi-word project names match greedily; an unresolved `#tag` just stays in the title). `/` runs a full-text search whose results overlay the task list; Enter opens a hit (a task at its context doc) in nvim.
- Move-to-project (§8.1): a new `task.set_project` RPC re-files a task under another project (or unfiles it) with OR-set link semantics — the old `in-project` link is tombstoned and a new one added, so a task is never filed under two projects at once. `heph edit <task> --project <name>` now routes through it (fixing a bug where re-filing piled on a duplicate link), and `--project none` unfiles the task.