AtlatestRenderedmarkdown
Readme

Changelog

All notable changes to folio are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[0.2.11] - 2026-08-11

Fixed

  • An unknown argument is refused instead of ignored. folio --typo used to start the MCP server and exit 0, so a mistyped flag looked exactly like a successful run. Unknown arguments now exit 1 with a usage message naming what is actually valid, and --help / -h work with no environment configured at all (previously --help required FOLIO_ROOT).
  • --version and --help no longer bypass validation. They were scanned for across the whole argument list before validation ran, so folio migrate-buried --version printed a version and exited 0 — the same silent success this release exists to remove, reachable through the fix for it. The check is a property of what can reach it, not of the check itself.
  • The validator no longer disagrees with the code that reads the arguments. folio --log --web was accepted as "--log with the value --web", after which the port finder started an HTTP server nobody asked for and the logger wrote a file literally named --web, all at exit 0. A validator that blesses an invocation the program then reads differently is worse than no validator.
  • --web-port no longer fails at runtime. The port check called exact?, which is unbound in sigil: it compiled cleanly and raised on every real --web-port invocation. Caught by a test arm that runs the actual deployment command rather than a synthetic one.

Added

  • --stdio is accepted and ignored, as a documented compatibility no-op. It names the transport folio already uses, and it is a common MCP client convention.
  • test/test-cli-contract.sh, 23 arms, each verified individually falsifiable against a stubbed CLI. Some arms deliberately run live deployment invocations rather than defect cases; they are marked as such because that is what caught the exact? runtime failure, and they are the opposite of redundant.

Known

  • --log-level=trace is refused with a correction pointing at --log-level trace. The consumers match flag names exactly and never split on =, so accepting the joined spelling would start folio and silently ignore the flag. Accepting both spellings honestly requires folding the three argument parsers into one pass, which is tracked separately.
  • serve remains accepted. It was never a subcommand: there was no dispatch, and the old loop ignored unrecognised arguments, so folio serve has always worked by accident. Live deployments depend on it.

[0.2.10] - 2026-08-06

Fixed

  • A multi-line capture round-trips: an item's extent is SYNTACTIC, and the burial class is closed. A task is its checkbox header line — metadata at the end of that line — plus the run of following lines indented at least two columns past it (one blank line is allowed between body lines when bracketed by indented ones). Extent is decided by indentation alone, never by content: no heading, fence or hashtag heuristics anywhere, so a removal can only ever take lines indented under the item's own header, and unindented prose, headings and config fragments are untouchable by task operations by construction.

This ends the mechanism that buried at least 22 items between April and August 2026: the old writer appended {id: ...} after the whole text, a newline put it on the capture's LAST line, the line-oriented parser saw only the headless first line, and backfill-ids! then minted a phantom id onto it — one capture became a phantom head plus an invisible tail, while folio/status reported the inbox empty. Writers now put metadata on the header and indent the body; every mutation (complete, reopen, update, move, triage, sweep, clean) operates on whole blocks by their recorded line extents. Moves also now preserve completion — a completed task used to arrive reopened while still carrying its completed: date.

  • Buried items are reported loudly, and id assignment freezes around them. A new independent scanner — sharing no code with the parser, by design and by lesson — walks raw file text for t- ids and compares against what the parser actually produced. Anything written on disk but unreachable by every query is named, with its line, in folio/inbox and folio/status. backfill-ids! refuses to touch a file holding a buried id, so the phantom-mint path is closed from both ends. Ids inside terminated column-0 HTML comments are annotation and are not flagged; ids quoted inside a visible task's own text are that task's content, not a burial.
  • Rewrites that would destroy bytes now refuse instead. A header whose parse is lossy (stray braces around the metadata) loses part of the line on every re-serialize — completing one such task destroyed its tail, and a plain LISTING destroyed {braces} in text} via backfill (a defect that predates this release). Every in-place rewrite, move and triage now checks the header survives parse→serialize byte-identically and refuses by name when it does not; backfill skips such lines.
  • Text containing tool-call markup is refused at the door. </text>, <parameter name= and </parameter> can never be legitimate captured content — their presence means the client's tool call fell apart and its argument list leaked into a value (this buried two items live on 2026-08-06). All string arguments of inbox-add, task-add and task-update are checked; nothing is stored on refusal.

Added

  • folio/migrate-buried — explicit recovery of legacy buried items. Dry-run by default: prints per-id actions and a unified diff, writes nothing. It converts the two understood burial shapes (headless checkbox head with a trailing-metadata body; a bare prose block ending in an id line), REFUSES what it cannot convert without guessing — a bare-id phantom head above the tail (recovery is a human MERGE of two tasks), and tool-fragment shapes whose metadata does not close on its own line — and verifies every conversion with the independent scanner before accepting it. A conversion deletes nothing: lines are reindented and one metadata substring relocated, line count preserved exactly.

Compatibility

  • Files written by 0.2.10 remain readable by older binaries: headers are unchanged as task lines, and indented bodies read as inert prose. Older binaries cannot SEE bodies (they list the header line only) and must not be used to mutate a store containing multi-line items — their line-based removal would strand bodies. Upgrade the binary before writing.

[0.2.9] - 2026-08-05

Fixed

  • The note walk no longer has a size ceiling, and folio can start again. find-md-files-recursive folded each directory's entries with fold-right, which is not tail recursive — it burned one interpreter stack frame per file. The maximum size of a folio was therefore set by the interpreter's stack rather than by anything folio chose, and past roughly 1011 entries in a single directory the walk blew the stack outright.

Because every tool that enumerates notes starts at that walk, the blast radius was the whole server. A fresh folio serve could not finish starting — it died during index reconciliation, after logging Opened search index and before MCP server ready. In an already-running server, startup had succeeded back when the corpus was smaller, so the process stayed healthy while individual calls began to fail: note-links, status and note-rename stopped answering, and everything that addresses a note by path (note-read, note-create, note-patch, the task tools) kept working. That partition is what the defect looks like from outside.

The walk is now iterative, with an explicit worklist. Stack depth is the directory nesting depth — single digits — instead of the file count. Ordering is unchanged: directory-list order, depth-first, subdirectories expanded in place. Verified against a real corpus with all 2460 notes in one flat directory, 2.4x past the old ceiling, at no measurable cost (cold reindex 14.0s before, 14.2s after).

Measured, on a copy of a real vault: 1010 entries fine, 1012 fatal. Six empty notes containing no links at all were enough to cross it, and the same threshold held whichever six were added — it was the file count, not the content and not the link graph.

  • A tool that dies mid-call now says so instead of going silent. When a handler raised through the internal CPS frames of a higher-order stdlib procedure, the native backend mis-unwound it: the dispatcher's guard never ran, no JSON-RPC response was written at all, and the caller blocked until its idle timeout while the server stayed up and answered later calls.

Silence is the worst failure a caller can be handed — it is indistinguishable from slow and from succeeded, so the reasonable response is to retry, and a retry of a mutation whose disk write already landed double-applies. This was not hypothetical: a note-rename that never answered had already renamed the file and updated none of its crosslinks.

Every tool handler is now wrapped. Ordinary failures are re-raised unchanged, so they still arrive as a proper JSON-RPC error envelope; stack exhaustion — the one condition measured to be unserialisable — is returned as an explicit report that also warns the change may be partially applied. It is a known-list rather than a catch-all: an unmeasured condition keeps existing behaviour instead of getting a guess.

Known issues

  • note-rename is not atomic. It renames the file first, then rewrites crosslinks across referring files with no transaction and no rollback, so a failure partway through leaves the note renamed and some or all crosslinks stale — with no error if the failure was the silent kind described above. The walk fix removes the cause seen so far, but not the exposure. Tracked separately.

[0.2.7] - 2026-08-02

Fixed

  • Moving a task no longer renumbers it. Ids are preserved in every direction — inbox → project, project → inbox, project → project, and folio/inbox-triage. An id you hold before a move is still valid after it, so task-move → task-update <same-id> chains correctly and a triage plan built from an inbox dump keeps working once the items are routed.

This was never a design constraint. Ids are globally unique random hex with no per-project id space, so nothing required a new one on move. project-add-task! simply called generate-task-id unconditionally and ignored any id handed to it, while move-task-in-store! stripped the id out on the way in. The asymmetry gave it away as an oversight: project → inbox passed metadata through untouched, and only inbox → project destroyed the id.

  • A task can no longer end up carrying two id entries. Moving a task from a project back to the inbox prepended a fresh id without removing the existing one, leaving both on the line; lookups silently resolved the first.

Added

  • folio/task-move-batch — move many tasks in one call, either {ids: [...], project: "x"} for a shared target or {moves: [{id, project}, ...]} to fan a single call out across many projects. An entry with no project moves that task to the inbox.

Returns a result line per id so partial failures are visible rather than swallowed, and the header counts successes rather than attempts. Because ids are now stable, the reported id is the one the task landed under.

Triaging 227 inbox items on 2026-08-02 cost 226 individual round-trips, which is a large part of why that inbox went untriaged for four months.

Changed

  • Toolchain moved to sigil 0.18 (sigil ^0.18, sigil-run ^0.18.0, sigil-http ^0.18.0, sigil-sxml ^0.15.0), with runtime core resolving to 0.18.1. sigil-test / sigil-test-runner remain pinned at ^0.17 (0.17.16): bumping them aborts the install because sigil-http, sigil-tls, sigil-crypto and sigil-web-styles each declare a dev-dependency on sigil-test ^0.17 and the resolver unifies across the graph. That affects the test harness only, not the shipped runtime, and clears once those four repos move their dev-dep.

Known issues

  • A capture containing newlines is still stored in a shape folio cannot read back. folio/inbox-add accepts multi-line text, but the {id: ...} metadata is appended to the END of the text, so on a multi-line capture it lands on the item's last line. The parser sees only the first line, which has no id, and backfill-ids! then mints a fresh id onto it — the real item and its metadata become unreachable and a phantom one-line duplicate appears in its place. Six items were hidden this way between April and August 2026.

This is not fixed in 0.2.7. A block-parsing fix was written and withdrawn: it cured the burial but introduced worse defects, because it inferred where an item ends and then let destructive operations act on that inference. The redesign makes an item's extent syntactic rather than inferred.

Until it lands, capture multi-line content as a note and reference it from a single-line inbox item, rather than putting the body in the item itself.