A hand-made `wiki` link row looked like it worked, then silently
vanished on the next body write — the links table's wiki rows are an
index derived from `[[…]]` in the body, and `sync_wiki_links` diffs
them against it. Store::add_link now rejects `wiki` (with a pointer at
the body as the durable path) plus the `canonical-context` / `log-of`
task internals. Body materialization uses the internal path and is
unaffected; `link.add` ops arriving over sync still apply.
Drops the wiki-links explanation card — it existed to document the
footgun, and the footgun is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- conflicts.resolve now applies the chosen value (local/remote) as a new
task.set op — peers converge on the decision — instead of only marking
the row resolved.
- heph node restore <id> — undo of node rm.
- heph project move <name> --parent <p>|--root — post-creation
re-parenting from the CLI.
- heph show on a task prints its canonical-context doc body (the task
node's own body is always null and was hiding the real content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sync.status now carries a runtime block — version, mode, sync cadence,
and self-update state (interval + last check/outcome, tracked by a new
SelfUpdateHealth shared with the poll loop). `heph daemon status` asks
the live daemon and prints it under the service facts: hub + oidc, sync
health at a glance, open-conflict count, self-update status.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CLI's attention flag (on task/list/attention/edit/promote) now takes the
a1–a4 labels, a bare digit 1–4, or a colour word, normalizing to the storage
colour before the RPC. Adds Attention::parse_input() in heph-core (lenient
human input) alongside the strict storage parse(), with a clear error listing
the accepted forms. `heph attention` now echoes the band as `a1 (red)`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spoke OAuth path funneled every failure into one `AuthError::Provider`
whose Display was hardcoded "identity provider unreachable". So a reachable IdP
returning `400 invalid_grant` on a refresh was reported as "unreachable",
misdirecting incident response toward the network when the fix is re-auth. The
real refresh cause was also swallowed — `bearer()` logged it and returned None,
so sync health only ever showed the downstream 401 on /sync/pull.
Wording fix (auth.rs / oauth.rs):
- Split AuthError into Unreachable (transport), Rejected (IdP returned an HTTP
error — carries the RFC 6749 §5.2 error/error_description), and Other
(keyring / malformed response, previously mislabeled too).
- refresh()/discover()/start()/poll() classify transport vs status; refresh
reads the OAuth error body on a non-2xx.
- Hub-side token verify maps IdP-infra failures → 503, token failures → 401.
Recovery UX (server.rs / heph / heph-tui):
- bearer() returns Result; the sync paths record the real acquisition failure
(with a re-login hint when it's a rejection) instead of a masked 401.
- sync health's last_error carries the exact `heph auth login --hub-url …
--issuer … --client-id …` command (keyed to the configured hub); sync.status
also returns issuer/client_id + the command.
- New `heph auth status` prints auth health and the re-login command.
- heph-tui's auth chip points at it: `⚠ auth · heph auth status`.
Closes the duplicate "misleading identity provider unreachable" tasks and the
"actionable re-auth guidance" task. Also corrects a now-stale set-up-sync-hub
gap note (daemon config baking landed in the prior PR).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`restart` bootstrapped immediately after `bootout`, but `launchctl bootout` is
asynchronous: launchd may still be killing/reaping the job and removing its
label when the command returns. Bootstrapping into that transitional domain
fails with a generic `5: Input/output error`, intermittently — the odds depend
on how fast hephd (sync client + SQLite + a heph-quickadd child) shuts down.
- Wait for the label to actually clear (poll `launchctl print`, bounded) before
re-bootstrapping, and retry the bootstrap to cover the residual settle window.
- When the plist is unchanged (the common binary-upgrade restart), use
`launchctl kickstart -k` to restart the loaded job atomically — no
bootout/bootstrap, no race. The full reload path is reserved for genuine
config changes, where launchd must re-read the plist.
Start's bootstrap shares the same retry helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`heph daemon start`/`restart` previously hardcoded `hephd --mode local` and
only wired the bare `--self-update` bool — the poll interval and all spoke/hub
sync config (`--hub-url`, `--http-addr`, `--oidc-*`) could not be set on the
managed service without hand-editing the plist/unit (which a later
start/restart would clobber).
Generate the hephd arg vector from a DaemonConfig and add the corresponding
`heph daemon start/restart` flags: --mode, --hub-url, --http-addr,
--oidc-issuer, --oidc-audience, --oidc-client-id, and
--self-update-interval-secs. Regenerating now reads the existing service file
and preserves any flags not passed (start as well as restart), so a bare
invocation never silently drops baked config.
Closes the "pass through --self-update-interval-secs" and "bake hub/spoke
config into the generated service" backlog tasks.
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>
Self-restart works by exiting cleanly and letting the service manager
respawn the new binary. launchd already does this (KeepAlive=true), but
the systemd user unit was Restart=on-failure, which ignores a clean
exit (code 0). Switch to Restart=always + RestartSec=1, update the unit
test, and note in run-the-daemon that existing Linux installs must
`heph daemon restart` once to regenerate the unit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Editing a task's canonical-context doc body previously meant looking up
its `canonical_context_id` (e.g. via `heph list --json`) and then
`heph node update <doc-id> --body`. Add a `heph context <task-id>`
command that resolves the canonical-context doc from the task's outgoing
links and:
* prints the body with no flag,
* `--body <text>` replaces it (`-` reads stdin, matching `node update`),
* `--append <text>` adds a blank-line-separated paragraph.
Errors clearly when the id has no canonical-context doc (e.g. a plain
doc node rather than a task). Purely a client-side CLI convenience — no
new RPC.
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>
`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>
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>
`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>
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>
Todoist uses "every 5th" for monthly-on-the-5th; map it to
FREQ=MONTHLY;BYMONTHDAY=N (1..=31). Surfaced by the Todoist import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make heph a real task driver and the complete daemon-API surface (the
three-surface model's capture/scripting role). Structured fields are flags.
- datespec: human date parsing (today/tomorrow/+3d/fri/ISO, injectable today
for deterministic tests) + compact display; recurrence presets + the common
Todoist-style natural-language forms ("every 3 days", "every fri", "every
April 15") + raw RRULE passthrough. Table-driven unit tests.
- main: new commands covering every RPC — list, done/drop/skip, attention,
edit (reschedule via task.set_schedule), promote, show, log (append/tail),
health, node update/rm, resolve, links/backlinks, link add,
project add [--parent], sync [--status], conflicts [resolve]. task/next/list
show human dates; projects referenced by name (resolved, errors if absent).
- tests/cli.rs: real-socket process tests for the new verbs.
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>
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>