A spoke could be silently failing to sync (expired token → 401, or hub
unreachable) with the only signal buried in the daemon log. Now:
- hephd tracks SyncHealth (last attempt/success time, last error, auth-failure
flag) from the background sync loop and sync.now, classifying a 401 as an auth
failure. sync.status returns it plus the pending merge-conflict count.
- heph-tui shows a live status-line indicator (spoke only): '⟳ <age>' since the
last good sync, red '⚠ auth' when re-login is needed, '⚠ offline' when the hub
is unreachable, and '⚠ N conflicts' when conflicts are pending. The event loop
polls on a 2s tick so the age advances and failures appear while idle.
- docs: recommended Authentik access/refresh token validity to stop frequent
re-logins (with the iOS PWA localStorage-eviction caveat).
Closes the 'Add hub connection status to heph-tui' and 'Spoke sync health:
surface unhealthy state instead of silent 401 spam' backlog items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundles the cosmetic/UI-polish backlog for the agenda surfaces. All read-side;
no schema or sync change (see hub-spoke-data-evolution).
- humanize_rrule (hephd::datespec): inverse of parse_recurrence — renders an
RRULE as 'every other week', 'weekdays', 'yearly on Apr 15', etc.; falls back
to the raw rule for unmodeled parts (COUNT/UNTIL/ordinal BYDAY). Mirrored in
the PWA's datespec.js. Shown in the TUI recurs detail line and PWA task/qa
previews instead of the raw FREQ= string.
- project.overview RPC + Store::project_overview: each project's parent (via the
existing 'parent' links) and direct outstanding-task count, a read-only query.
- TUI sidebar: subprojects indented by depth, per-project counts, wider pane,
and ListState + scrollbar so it scrolls instead of clipping on overflow.
Tests: humanize parity (Rust + JS), round-trip through parse_recurrence,
raw-passthrough; project_overview count/parent; sidebar tree ordering + cycle
safety.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the manual bearer-token paste with a proper browser OIDC sign-in.
- Hub: unauthenticated GET /config -> {issuer, client_id} (added after the auth
layer), sourced from the verifier's new TokenVerifier::oidc_config(). Lets the
PWA self-configure when served from the hub. Tests in web_serve.rs.
- PWA: src/oauth.js implements PKCE (S256), the authorize redirect, the callback
token exchange, and silent refresh (offline_access). Settings gains a "Login
with Authentik" button (manual token kept under a fallback disclosure); rpc.js
retries once on 401 via a refresh hook; app.js completes the callback / refreshes
on load; sw.js skips caching the callback URL and ships oauth.js in the shell.
Requires the PWA origin registered as a redirect URI on the Authentik provider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Popover supervision was gated to Mode::Local, so running the store-owning
daemon in server mode (now needed to host heph-pwa) silently dropped the
desktop quick-capture popover. Server mode is local + an HTTP hub and owns the
same store/socket, so it should drive the popover too; broaden the guard to
Local | Server (client, a thin proxy, still opts out).
Also: when the PWA shell is served from the hub, default the hub URL to its own
origin so the app is zero-config on first open (Settings still overrides). Bump
the service-worker cache to v2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a permissive CORS middleware (answers the browser OPTIONS preflight and
stamps Access-Control-* on every response) and an optional --web-root static
file handler with an index.html SPA fallback. Together these let a browser
surface — the forthcoming heph-pwa mobile app — call /rpc cross-origin or be
hosted same-origin straight from the hub. No new crate dependencies; file
reads run on the blocking pool. Covered by tests/web_serve.rs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hephd's reqwest client is built default-features=false with no TLS
feature, so the self-update release poll's HTTPS GET always failed
('release check failed: requesting forge releases/latest') — the bug
never surfaced before because nothing in production used reqwest over
HTTPS (hub sync is plain http://). Switch the poll to ureq, which is
already a dependency and ships a rustls/ring TLS stack needing no system
libs (notably no cmake/aws-lc-sys, which would break the rust:bookworm CI
image). Verified end-to-end: a 0.0.0 build now detects v1.1.0, installs,
and restarts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repo is public, so self-update needs no credentials: cargo install
--git is a plain anonymous clone (NOT the access-restricted Forgejo cargo
registry, which is what required forge.ops.eblu.me). Point INSTALL_GIT_URL
and the releases poll at the canonical public host over HTTPS — verified
end-to-end (cargo install --git https://forge.eblu.me/... --tag v1.0.3
builds a working hephd with zero auth).
Make the headless service able to run the apply path: 'heph daemon
start --self-update' (default off) generates a launchd/systemd service
that passes --self-update and bakes a PATH (incl ~/.cargo/bin) + HOME so
the minimal service env can find cargo. restart preserves the setting.
Default (no flag) services are byte-identical to before. Template + URL
behavior covered by unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The poller now installs + restarts (not just logs); fix the stale doc and
point at service-env-forge-access as the deployment step that makes the
apply path operational.
Add a Restarter trait + ProcessRestarter (exit 0 so launchd KeepAlive /
systemd Restart=always respawn the new binary). apply_update now installs
then restarts, and the restart fires only on a successful install. Wired
into the poll loop. Unit-tested with fake installer+restarter: restart on
success, no restart after a failed install. Real process exit is never
run in tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an Installer trait + CargoInstaller (runs cargo install --locked
--git <ssh> --tag <tag> for heph/hephd/heph-tui/heph-quickadd — the
documented install command, via the SSH host that sidesteps the
cargo/forge canonical-name mismatch), and apply_update() which runs the
blocking install on the blocking pool. The poll loop now applies on a
detected update. Apply path is unit-tested with a fake installer (call +
failure paths); the real cargo subprocess is never run in tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lock in the base-case guarantee that a self-updating hub (which restarts
under its spokes) relies on. New sync_http test: a spoke whose hub is
unreachable keeps serving + accepting writes, a sync attempt fails fast
(Err, not hang/panic), and when the hub returns the accumulated ops
reconcile with no special recovery.
The verification surfaced one non-graceful path — the daemon's shared
reqwest client had no timeout, so a black-hole hub (connects, never
replies) could stall the sync/self-update loop. Give it a 30s timeout so
'the hub can vanish at any moment' holds even mid-request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a ReleaseSource trait (real ForgeReleaseSource over HTTP; injectable
for tests), check_release() returning a CheckOutcome
(UpToDate/UpdateAvailable/Failed) that never errors so a flaky forge
can't stall the daemon, and run_poll_loop() that ticks on the configured
interval and logs when a newer release is available. spawn_self_update_loop
now spawns the real poller. Detection is unit-tested with a stubbed source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add --self-update (default off) and --self-update-interval-secs to the
hephd CLI, a SelfUpdateConfig (Some => enabled), and thread it into the
Daemon (with_self_update) for every mode. spawn_self_update_loop()
currently just announces the mode at startup ('self-update enabled')
so the opt-in is observable; the poll/apply cycle is wired in later
leaves. Omitting the flag leaves behaviour unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add crates/hephd/src/selfupdate.rs: a pure update_available() that
compares the running heph_core::VERSION (e.g. "1.0.3 (sha)") against a
release tag ("v1.0.4") via semver, ignoring the build suffix and v
prefix; plus parse_latest_tag() / fetch_latest_tag() for the forge
releases/latest feed. Decision logic and JSON parsing are unit-tested
against sample payloads; the network fetch is isolated. Adds the semver
workspace dep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The project.resolve socket test had a node.create call over rustfmt's
line limit; CI's `cargo fmt --all --check` flagged it. Wrap it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RPC clients (the hephaestus.nvim plugin, for its `:Heph version`
command) had no way to learn which hephd they are talking to — `health`
returns counts, not a version. Add a tiny `version` method returning
`{ version: heph_core::VERSION }`, the same `X.Y.Z (sha)` string the
binaries print for --version. No store access needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `--project <name>` argument matched titles case-sensitively and
exactly, so `--project hephaestus` or `--project heph` failed against a
`Hephaestus` project. Make project-name resolution forgiving but
deterministic, via a tiered match in `resolve_project_id`:
1. exact (case-sensitive) — the historical behavior; always wins
2. case-insensitive exact — only when unambiguous
3. case-insensitive prefix — only when unambiguous
Ambiguous fuzzy matches resolve to None (callers report "no project
named X") rather than silently picking one. This single resolver already
backed `heph list --project` (via project_scope); route the CLI's
task/edit/promote/parent path through it too with a new `project.resolve`
RPC + `Store::resolve_project`, so every `--project` surface behaves the
same.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
heph-core gains a build.rs that captures the short git SHA and a
`heph_core::VERSION` const ("<crate-version> (<sha>)"); heph and hephd use it
for clap's --version. The crate version stays sourced from Cargo.toml.
release.yaml now bumps the workspace version into Cargo.toml + Cargo.lock on a
commit that only the tag points at, tags it manually, and pushes just the tag —
so cargo install --git --tag vX.Y.Z reports the real version while main stays at
0.0.0. The changelog commit moved ahead of the tag so the release includes its
own changelog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
keyring 4's `keyring` meta-crate has no feature gating and compiles every
platform credential backend for the target. On Linux that dragged in the zbus
async stack, a redundant libdbus secret-service, the keyutils store, a
sqlite/zstd db-keystore, and OpenSSL (~290 crates in its subtree) — a real cost
on the RAM/CPU-constrained CI runner building with CARGO_BUILD_JOBS=1.
Depend on keyring-core (the API) + exactly one store crate per OS instead:
- macOS -> apple-native-keyring-store (keychain feature)
- Linux -> dbus-secret-service-keyring-store (crypto-rust; libdbus, no openssl)
oauth.rs registers the per-target store as the keyring-core default itself
(replacing keyring::use_native_store). Runtime behavior is unchanged (tokens
still go to the macOS Keychain / Linux Secret Service).
hephd's Linux dependency graph: 401 -> 235 crates (-166), dropping the zbus
ecosystem and two C builds (zstd-sys, plus the redundant secret-service path).
macOS builds + the full suite are green here (228 tests, clippy -D warnings,
fmt, prek); the Linux store path is CI-verified (API confirmed from source).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cheap "seam" that keeps the single-owner hub from calcifying, ahead of
the gilbert -> indri bring-up:
- Replace the single-tenant gate `Store::authorize_owner_sub(sub) -> bool`
with `resolve_owner(sub) -> Option<owner_id>`. The hub auth middleware now
resolves the token's identity to the owner it may act as (Some -> allow,
None -> 403). Behavior is identical for the single-owner hub (claim-on-first;
strangers still 403), but the contract no longer assumes one global owner, so
serving N owners later is additive, not a rewrite. The per-request owner is
marked at the exact line where downstream scoping wires through.
- New how-to docs/how-to/set-up-sync-hub.md: stand up the hub and connect an
existing device as an offline-capable spoke, the data-safe way (Path A: the
hub adopts the device's identity rather than rewriting the device).
The decision (cheap seam now, defer full multi-tenancy + adoption rewrite) is
recorded in the Adoption + multi-tenant task's context doc. Two enabler gaps
the how-to surfaced (heph daemon hub/spoke service flags; Path-A seeding tool)
are filed as Hephaestus tasks.
Green: 228 tests, clippy -D warnings + fmt + prek clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump all external crates to latest stable ahead of v1.0.0 (tech-spec §14.9):
keyring 3→4 (the keyring_core split + register-the-native-store model),
rusqlite 0.32→0.40, ratatui 0.29→0.30, rrule 0.13→0.14, yrs 0.26→0.27,
plus a cargo update for semver-compatible bumps.
keyring 4 moves Entry/Error into keyring_core and requires a credential
store to be registered before use; KeyringTokenStore now registers the
OS-native store once (lazily, via Once) and uses keyring_core types.
not_keyutils=true so Linux prefers Secret Service over the logout-wiped
kernel keyutils store.
Drop the fs4 dependency in favor of std::fs::File::try_lock (stable since
Rust 1.89); raise workspace MSRV 1.85→1.89. Remove the orphaned
.forgejo/scripts/build hook — CI invokes Dagger directly.
Green: 228 Rust tests + 25 heph.nvim headless e2e, clippy -D warnings + fmt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`heph list --project <name>` lists a project's outstanding tasks by name
(subtree-expanded, resolved server-side via a new project.scope path that
reuses the view machinery; errors on unknown names). `--json` prints raw
rows — node_id, canonical_context_id, attention/state/do_date/late_on/
recurrence/project_id — for scripting and agents. Store::project_scope on
the trait + LocalStore + RemoteStore; new project.scope RPC and a flattened
ListParams so `list` accepts an optional project name. Test covers
resolve-by-name + unknown-name error.
AGENTS.md thinned to tight command/pattern sections: dropped the historical
parity narrative and the verbose roadmap section; added a "Working state"
section documenting `heph list --project Hephaestus [--json]` as the way to
inspect heph's self-hosted roadmap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run rustfmt over files landed in earlier commits this session that weren't
fmt-checked (heph-quickadd, the heph-tui undo/move wave, the hephd quickadd
supervisor). Pure formatting (struct/if-else expansion, line wrapping); no
behavior change. Restores `cargo fmt --check` clean for CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Project delete previously tombstoned only the project node, leaving its
tasks with a live in-project link to a dead project — orphaned (not in the
Inbox, unbrowsable, blank project) rather than unfiled as intended. New
atomic Store::delete_project tombstones every in-project link to the project
(tasks fall to the Inbox), then tombstones the project node; tasks are never
deleted. Exposed as the project.delete RPC (LocalStore + RemoteStore); the
heph-tui sidebar `D` now routes through it. Core test asserts the task
survives and becomes unfiled; the project node is tombstoned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A macOS global quick-capture popover to retire Todoist. New `heph-quickadd`
crate: an always-warm eframe/egui agent that registers ⌘' (global-hotkey,
Carbon — no Accessibility permission) and toggles a hidden, pre-created
window visible + focused on press — never spawning on the keypress, so it's
a muscle reflex. A single field live-parses Todoist-style inline syntax via
the shared parser; chips show ⚑ attention · 📁 project · ⏰ do-date · ↻
recurrence as you type. Enter saves optimistically (hide now, task.create
on a bg thread; a failed RPC re-shows with the text restored). #project
autocomplete (Tab/↑↓/click; focus-locked so Tab completes instead of
traversing). Example hints rotate, fading in only after ~2s idle.
Parser lifted from heph-tui into hephd's lib (hephd::quickadd) so the TUI
and the popover share one parser. hephd supervises the helper as a child in
local mode on macOS (opt-in HEPH_QUICKADD=1, set by the installed launchd
plist) — one service to manage, no second launch agent; the helper
self-exits when orphaned so killing hephd leaves nothing behind.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `<Enter>` now opens the selected task's context doc in nvim (App::enter:
from the sidebar it drills into the task list first); the `o` binding is
retired. Hint line updated.
- BUILTIN_VIEWS reordered to the owner's preference — Top of Mind, Tasks,
Work Tasks, Chores, On Deck — which drives the TUI sidebar and
`heph view`. Tests that walked to On Deck by a fixed offset now seek it
by title.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The picker listed every node, so each task showed up twice (itself + its
same-titled canonical-context doc) plus tag/log noise — and the new
preview made the duplicates look identical. New `Store::list_linkable_nodes`
/ `node.linkable` returns non-tombstoned nodes minus `tag`s and the docs
that are a task's canonical-context or log attachment (you link the task,
not its body). The Telescope picker now sources from it.
Tests: a socket test (5 nodes → 2 linkable: task + standalone doc).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`wikilink::to_ids` rewrites name-addressed links to the canonical id
(id-first resolve: an already-id target is left alone, a name → its id
with any label preserved). `Store::migrate_wikilinks_to_ids` runs it over
every body and re-saves through update_node (which collapses + materializes
by id); idempotent. Surfaced as the `migrate.wikilinks` RPC + RemoteStore
forward + the `heph migrate-links` CLI command (not auto-run — the owner
runs it once per store).
Name-resolution + the canonical-context hack stay for now so legacy links
keep resolving pre-migration; retiring them is a later tidy. Tests:
to_ids unit + a heph-core migrate integration (rewrite + materialize +
idempotency).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep canonical `[[NODEID]]` links readable without storing names. New pure
`heph-core::wikilink` (injected id→title): `expand` turns a bare `[[id]]`
into `[[id|Current Name]]`, `collapse` turns a name-matching `[[id|text]]`
back to bare (a custom label is preserved as an override).
- `node.get` expands on every read (nvim buffer + TUI preview both
readable), then prepends frontmatter when asked.
- `update_node` strips frontmatter, then collapses links, then CRDT-diffs
— so neither projection ever persists and an unchanged read→write is a
no-op to the bare id.
Tests: wikilink unit (expand/collapse/round-trip), a heph-core collapse
+ materialize-by-id integration test, and a socket expand→collapse
round-trip. `heph export` still emits raw ids (later polish).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend: `links::resolve_id` now checks for an exact live node id before
alias/title, so a canonical `[[NODEID]]` link resolves to its node and
can't be shadowed by a like-named node. Legacy `[[Name]]` links still
resolve by name (until the migration), so this is additive.
heph.nvim: `link.insert` (bound to insert-mode `[[` and `:Heph link`)
searches via the `search` RPC and inserts `[[NODEID]]`, with a "+ Create
new doc" entry; `<CR>` follow resolves the id directly. e2e covers
search→insert→materialize and the create path.
Remaining (§8.4): read-expansion/conceal display + the one-time
[[Title]]→[[NODEID]] migration (then retire name-resolution + the hack).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The store-side half of the frontmatter edit surface:
- heph-core `frontmatter::strip` runs in `update_node` before the yrs
diff, so frontmatter never enters the body or CRDT. Conservative (only
a leading `---` block whose first line is a YAML key; a prose hrule
survives) and idempotent → read→write round-trip is a no-op.
- hephd `frontmatter::render` (local-tz dates via new `datespec::fmt_iso`)
behind `node.get {frontmatter: true}`: id/kind/title/tags, and for a
task or its canonical-context doc the owning task's scalars + a `task:`
ref. Subject-task + project-name resolution in dispatch.
Safe against any client (inbound frontmatter always stripped). Tests:
strip unit (incl. hrule/idempotency), render unit, socket round-trip +
task-context-doc projection. The heph.nvim diff-into-RPCs layer is next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A tag is a `tag`-kind node with a deterministic id in (owner, name)
(`tag:<owner>:<name>`, like the journal), so a name is one canonical tag
shared across nodes and replicas converge with no duplicates. Tagging is
an OR-set `tagged` link (mirroring in-project):
- heph-core: `nodes::open_or_create_tag` (bodyless, deterministic id),
`tags::{add,remove,of}`, and `Store::{add_tag,remove_tag,tags_of}`.
Enumerate all tags via the existing `list_nodes(Tag)`.
- hephd: `tag.add`/`tag.remove`/`tag.list` RPCs + RemoteStore forwarding.
- heph: `heph tag add|rm|list` (a node's tags, or every tag).
Names are trimmed; canonical case/spelling normalization is deferred to
the zk import. Unblocks the `tags:` line of the frontmatter surface.
Tests: core add/dedupe/remove/canonical-id/trim/missing-node + a socket
add/list/enumerate/remove test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The T2c commit moved datespec.rs into hephd but left its wiring uncommitted:
hephd's lib never exported `pub mod datespec`, hephd lacked the chrono dep,
and the CLI still declared `mod datespec` for a file that had moved. The
working tree had these (so local builds passed) but the pushed tree didn't,
breaking `cargo install` of heph + heph-tui. Commit them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single-line input modal (centered popup, Esc cancels, Enter submits) drives
the two input-requiring gestures:
- a: guided capture — title → attention (o/r/b, blank=white) → do-date
(today/tomorrow/+3d, blank=none). If a project is the current sidebar
selection, the task is filed there (Todoist's add-within-project).
- e: reschedule the highlighted task's do-date (blank clears it).
Parse errors keep the input step (typed text isn't lost) and show in the
status line. Shared client-side date/recurrence parsing (datespec) moved from
the heph CLI into hephd's lib so both the CLI and TUI use one parser; heph-core
stays clock-pure. The CLI now uses hephd::datespec (no behavior change; its
tests moved with it).
Tests: add-flow + reschedule unit tests against a recording fake (asserting the
collected title/attention/project and the clear-on-blank double-option), plus
real-daemon integration tests that guided-add surfaces a task and reschedule
sets the do_date. 171 workspace tests; clippy/fmt clean.
With done/drop/skip/attention/blue (T2a) + nvim handoff (T2b), all four
day-one daily-driver gestures now land. NL single-line quick-add + search are T3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the owner's saved filters first-class so the agenda isn't one flat
list. `list` now takes a ListFilter predicate-as-data (heph-core::filter):
attention include/exclude sets, project-id scope, exclude_projects, and an
actionable do-date gate. New Store::view(name) resolves a built-in ViewSpec
— looking project names up to ids and subtree-expanding them through parent
links — then lists.
Five built-ins seeded from the Todoist queries (design §6.2.1): tom, ondeck,
chores, work, tasks (Schedule dropped — time-of-day isn't modeled on
date-grained do-dates). Surfaced as `heph view <name>` (no name lists them),
the `view` RPC + RemoteStore forward, and `:Heph view <name>` in nvim.
The list RPC/RemoteStore/CLI/heph.nvim migrate to the filter wire; legacy
--scope/--attention/--no-blue map onto it (nvim view.lua updated).
Tests: filter unit predicate, a views integration suite (subtree
scope+exclude, actionable gate, unknown-view error, absent-project empties),
a socket list/view dispatch test, two nvim e2e specs. 154 Rust tests + 18
nvim e2e green; clippy/fmt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces are connect-only; the daemon now runs as an explicit OS service so it
can be shared without any surface owning its lifecycle.
- service.rs: heph daemon start/stop/restart/status/uninstall, idempotent;
launchd LaunchAgent (macOS) / systemd user service (Linux); resolves hephd
next to heph else on PATH; pure plist/unit render fns unit-tested
- main.rs: Command::Daemon handled before connecting (like auth)
- hephd: default socket is now a STABLE <data-dir>/heph/hephd.sock when
XDG_RUNTIME_DIR is unset (was $TMPDIR — fragile for a persistent service;
macOS prunes /var/folders and the path varied per session)
- tech-spec §14: CLI + daemon-service done entries
Verified live on macOS: start/restart/stop/uninstall + CLI reaches the store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was no way to change a task's do-date, late-on, or recurrence after
creation (only attention/state had setters) — a real reschedule gap. Add a
single patch method covering the three schedule scalars with no setter.
- model: SchedulePatch with double-option fields (absent=leave, null=clear,
value=set), serde-skips absent fields so the distinction round-trips
- Store::set_task_schedule + LocalStore/RemoteStore impls; sqlite set_schedule
overlays present fields then records the LWW task.set op (sync-correct)
- rpc dispatch: task.set_schedule (id + flattened patch)
- tests: core set/clear/leave + missing-task; rpc_socket round-trip asserting
the absent/null/value semantics over the wire
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Backend: enrich `list` to return titled RankedTask rows (title +
canonical_context_id, via a shared ranked_from_row with `next`), so the
Organizational view needs no N+1 node.get. TDD: query_surface test asserts
list rows carry title + context id.
Plugin:
- view.lua: Tactical `next` + Organizational `list` rendered scratch buffers;
<CR> opens the row's canonical-context doc. Narrowed the node autocmd to
heph://node/* so view buffers (heph://next, heph://list) don't trip it.
- task.lua: 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).
- picker.lua: vim.ui.select with Telescope auto-upgrade (headless-safe).
- command.lua: :Heph next/list/capture/attention/done/drop/skip/log/search.
e2e: capture→next→open context→add/check checklist→done; recurring
fresh-checklist (complete rolls forward in place, next occurrence all-unchecked
— the §4.4 hard requirement). 6 specs green via `mise run test-nvim`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The primary surface begins (tech-spec §8): a Neovim plugin that is a thin
client of the local hephd over its unix-socket JSON-RPC.
- node.resolve {title} → Node|null (heph-core Store + dispatch): exact,
owner-scoped, non-tombstoned alias-then-title match — the same mapping that
materializes wiki links, so follow-link jumps to the node the stored link
points at (never fuzzy search). Unit + rpc_socket integration tests.
- heph.nvim/: vim.uv unix-socket JSON-RPC client (blocking call via vim.wait,
id-demuxed, partial-line buffered, luanil so JSON null → Lua nil; isolated
Sessions for tests). Buffer-backed nodes (heph://node/<id>, acwrite;
BufReadCmd→node.get / BufWriteCmd→node.update, whole-buffer body round-trips
exactly through the CRDT). [[wiki-link]] follow on <CR>. Daily journal.
:Heph command surface + completion.
- Headless e2e (§9): a self-contained busted-style runner (tests/e2e/runner.lua)
— no external plugins, no network, deterministic CI exit codes. Specs: journal
round-trip, follow-link (+ unresolved no-op), link-two-docs/backlink.
`make -C heph.nvim test` builds hephd and runs it.
Docs: heph-nvim reference card, §14 tracker (11a done; 11b/11c/11d queued),
changelog fragment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the auth loop: clients obtain a bearer token and present it to the
hub (tech-spec §13).
- oauth module: DeviceFlow (RFC 8628 — discover, start, poll handling
authorization_pending/slow_down, refresh) + StoredToken + TokenStore
(OS keyring via `keyring`, in-memory for tests) + current_bearer (loads
and refreshes-on-expiry).
- heph auth login/logout: runs the device flow, prints the verification
URL + user code, caches the token in the keyring.
- sync_once gains a bearer arg; the daemon (Daemon::spawn_sync_loop +
sync.now) obtains it via current_bearer; RemoteStore attaches it to /rpc.
--oidc-issuer/--oidc-client-id configure the spoke/client.
- Fix a latent panic: reqwest::blocking spins its own runtime and panics
inside the daemon's spawn_blocking pool. All blocking auth/proxy HTTP
(OidcVerifier JWKS, DeviceFlow, RemoteStore) now uses runtime-free `ureq`;
async reqwest remains only for sync_once. (Caught by the new e2e test.)
- Tests (offline): device flow + refresh + token store vs a mock OAuth
server; a full spoke->authenticated-hub loop (valid token accepted,
missing token rejected) signed by a runtime-generated RSA key.
112 tests green; clippy -D warnings + fmt + prek clean. Slice 10 (auth)
complete; next is heph.nvim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Authenticate op exchange at the network boundary (tech-spec §13). The hub
now requires a valid OIDC bearer token on /sync/* and /rpc; local mode is
unchanged (no auth).
- heph-core: Store::authorize_owner_sub — single-tenant gate that claims the
owner's oidc_sub on first sight, then authorizes only that sub (403 for any
other identity). LocalStore impl over users.oidc_sub; RemoteStore stub.
- hephd auth module: TokenVerifier trait (mockable seam) + OidcVerifier
(jsonwebtoken, rust_crypto). Strict validation: RS256 pinned, exact iss +
aud, exp/nbf, required sub; JWKS discovered + cached, refetched on unknown
kid (rotation). Claims/AuthError.
- Hub router takes Option<verifier>; an axum middleware on every route
extracts the Bearer token, verifies it off the async worker, and runs the
owner gate — 401 missing/invalid, 403 wrong identity, 503 IdP-unreachable.
Open (no auth) when unconfigured, for local dev.
- main: --oidc-issuer/--oidc-audience enable the hub verifier (server mode).
- Security tests, all offline: stub-verifier middleware (missing/bad/valid +
owner gate) and an adversarial battery driving OidcVerifier against an
in-process mock IdP — rejects expired, wrong iss/aud, unknown kid, tampered
signature, alg confusion (HS256/none), and missing sub. The RSA key + JWKS
are generated at runtime (rsa/rand/base64 dev-deps) so no key is committed.
- tech-spec: add an end-of-v1 dependency-refresh pass to the roadmap.
108 tests green; clippy -D warnings + fmt + prek clean. Next: client-side
device-code login + keyring (10b).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the online-only escape hatch — a no-replica daemon that proxies every
Store call to a server over HTTP (tech-spec §3.1).
- Daemon is now generic over the backing store (Arc<Mutex<dyn Store +
Send>>), so the same unix-socket surface fronts either a LocalStore
(local/server) or a RemoteStore (client). sync::router/sync_once and the
Ctx follow suit.
- New POST /rpc route on the hub router runs the full rpc::dispatch over
HTTP (result-xor-error body, always 200). dispatch gains task.get and
links.add so the proxied API is complete.
- RemoteStore (hephd): implements heph_core::Store by forwarding each call
to /rpc via a blocking reqwest client (Store is sync; the daemon only
calls it from the blocking pool). Error::Remote for transport failures;
NOT_FOUND is preserved as Error::NodeNotFound. Sync primitives are
stubbed (a client keeps no op-log).
- main: --mode client + --server-url; client skips the file lock and opens
no LocalStore.
- tests/client_mode.rs: a RemoteStore drives node/task/search/list/health
against a real HTTP server, and not-found maps back correctly.
102 tests green; clippy -D warnings + fmt + prek clean. Next: OIDC auth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the existing merge engine over the network so the everyday config
(local + hub_url) syncs through a hub. Transport ratified = axum HTTP/JSON
(tech-spec §6.1, §12).
- heph-core: SyncCursors model + Store::sync_state/record_sync over the
sync_state table (per-peer push/pull HLC cursors). Incremental, so each
exchange transfers only the tail.
- hephd::sync: the hub router (POST /sync/push, GET /sync/pull?after=<hlc>)
served from the shared LocalStore, and sync_once — a spoke's pull-then-
merge, then push-tail exchange, advancing the cursors. Idempotent: a
re-pushed op the hub already has is a no-op.
- Daemon carries optional hub config; sync.now/sync.status handled at the
daemon (they need the hub transport the store can't reach). conflicts.
list/resolve now reachable over the unix socket too.
- main: --mode local|server, --hub-url, --http-addr. server mode binds the
hub HTTP endpoint on the same store; a local+hub_url spoke background-
syncs on a 30s interval.
- tests/sync_http.rs: two spokes converge through a real-HTTP hub on an
ephemeral port — node propagation and a divergent-scalar conflict.
Unauthenticated/single-owner for now; OIDC + per-user scoping is slice 10,
client mode + RemoteStore is 9b. 100 tests green; clippy -D warnings + fmt
+ prek clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice query-surface, part 2 (tech-spec §6). Migration v2 adds an FTS5
external-content table over nodes(title, body), kept in sync by
insert/update/delete triggers (with a backfill for existing rows).
- Store::search(query): owner-scoped, tombstones excluded, best-match
first (FTS5 MATCH + rank). Exposed over RPC; `heph search` and
`heph journal` CLI commands added.
3 search integration tests (title/body match, edits reflected via trigger,
tombstone exclusion, all insert paths indexed). 79 tests green. This
completes the local feature surface; the remaining slices are the
distributed/auth/nvim layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round out the local query surface ahead of the distributed layer:
- list(scope, attention, include_blue): the Organizational survey —
outstanding committed tasks incl. backlog, with project/attention
filters and an include_blue toggle.
- health(): working-set tensions surfaced honestly — orange / active
(white+orange+red) / on-deck (blue) counts; conflict_count + sync_status
reserved for sync.
- journal.open_or_create(date): deterministic id in (owner, ISO-date)
(§3.1) so offline replicas converge; idempotent; rejects non-ISO dates.
- Exposed over RPC (list / health / journal.open_or_create).
6 integration tests; 76 total green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 6 (tech-spec §3, §6, §10). First async component — the per-device
daemon in local mode.
- `LockGuard`: exclusive advisory flock on a sidecar `<db>.lock`; a second
acquire fails and releases on drop (the §3.1 lock handoff).
- JSON-RPC (line-delimited): `rpc::dispatch` maps node/task/next/links/log
methods onto the heph-core Store; `Daemon::serve` accepts unix-socket
connections and runs dispatch on tokio's blocking pool behind an
Arc<Mutex<LocalStore>> (DB never touches an async worker).
- Synchronous `Client` for surfaces/CLI; `hephd` binary (clap) opens the
store under lock and serves the default socket.
- heph-core model/ranking types are now serde-(de)serializable; added
node.tombstone + Store::tombstone_node.
Tests: 2 lock unit tests + 5 real-socket e2e (round-trip with clock
injection, next, error paths, recurring roll-forward over RPC, 8-client
concurrency). 60 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>