Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ logseq-cli get-todos --page-type journal

# Filter by what the task says (regex, case-insensitive)
logseq-cli get-todos --match "review|contract" --json
# --json: {"todos": [{marker, content, page, uuid, journal_day?, ...}], "count": N}
# --json: {"todos": [{marker, content, page, uuid, hash, journal_day?, ...}], "count": N}

# Tasks standing in a date range, including ones carried forward by ((block-ref))
logseq-cli get-todos --from 2026-09-14 --to 2026-09-16 --json
Expand Down Expand Up @@ -470,10 +470,12 @@ rollback), and the message ends with how many, or "Nothing was written.".
| `logseq_error` | Logseq threw on the write and answered with an error object | `method`, `logseq_message` |
| `page_exists` | `create-page` on a page that exists, whose properties Logseq would drop | `page` |
| `rename_refused` | `rename-page` onto a name another page has (Logseq would merge the two) or an empty one | `old`, `new`, `why` (`exists` or `empty`) |
| `block_changed` | `set-todo-status`, `replace-text`, `set-property` and `remove-property` (every write that replaces a block's text) read the block again right before writing, and it no longer holds the text they read: someone changed it in between, and the write would have replaced that change. Nothing was written | `block` |
| `block_changed` | `set-todo-status`, `update-block --expect-hash`, `add-journal-block --upsert-heading --expect-hash`, `replace-text`, `set-property` and `remove-property` (every write that replaces a block's text) read the block again right before writing, and it no longer holds the text they read: someone changed it in between, and the write would have replaced that change. Nothing was written | `block` |
| `write_not_verified` | The write does not show in Logseq: not written, or not all of it | `method`, `target`, `expected`, `got` |
| `read_only` | Writes are switched off by `[safety] read_only`, `LOGSEQ_CLI_READ_ONLY` or `--read-only`; every command that writes refuses, `--dry-run` included, before its first request | `source` (list of `config`, `env`, `flag`), `config_path` (`null` when no config file was found) |
| `precondition_required` | A precondition is required and the call gave none: `[safety] require_preconditions`, `LOGSEQ_CLI_REQUIRE_PRECONDITIONS` or `--require-preconditions` is on, and the command got none of the options it takes: `--expect-hash` (`update-block` takes it too; `set-todo-status` also takes `--expect-marker`; `add-journal-block --upsert-heading` owes it only when it matches a block, otherwise it creates one and owes nothing), or `--expect-tree-hash` for `remove-block` / `delete-block`. Refused before the first request, nothing was written | `source` (list of `config`, `env`, `flag`), `config_path` (`null` when no config file was found), `options` |
| `config_error` | The config file cannot be used: it does not parse, `LOGSEQ_CLI_CONFIG` names a file that is not there, or `[safety]` does not check out: a key it does not know, a `[safety]` key outside `[safety]`, a value that is not a boolean, or a `[graph] keep_empty_blocks_last` that is not. A command that writes refuses rather than run without the limits the file may hold. Nothing was sent | none |
| `precondition_failed` | `--expect-hash` / `--expect-marker` (`set-todo-status`, `update-block`, `set-block-property`, `remove-property --id`, `move-block`, `copy-block --remove`, `add-journal-block --upsert-heading` when it matches a block: the hash is of that match, not of the `--under-heading` host; with no match the block is created and the precondition is moot), `remove-block --expect-tree-hash`: the block (for `remove-block`: the block or anything under it) is no longer the one the hash was read from, or its marker is not the expected one. Nothing was written. The current hash is not given, on purpose | `expected_hash`, `expected_tree_hash` and/or `expected_marker` (as given), `actual_marker` (`null` for a block without one), `first_line` |
| `block_not_found` | The block the command names does not exist; the placeholder Logseq keeps for a missing ref target counts as none | `id`; for `set-todo-status --content` the `content` and `page` searched instead, no `id` |
| `dead_ref` | `set-todo-status --follow-refs`: the chain of refs leads to a block that does not exist | `id` (the missing block), `followed` |
| `ref_cycle` | `set-todo-status --follow-refs`: the chain of refs comes back to a block it passed | `id` (the block met again), `followed` |
Expand Down Expand Up @@ -511,6 +513,22 @@ without moving the cursor. An insert whose text holds `((X))` is refused
while X is open in the editor and has no `id::` yet (storing the id would
write into X). A `--keep-ids` write is refused while any block is open.

On `precondition_required`: read the block with `get-block --json` and pass
its `hash` as `--expect-hash` (or the marker you decided on as
`--expect-marker`). Do not switch the requirement off to get past it; the user
set it. `read_only` is checked first: if both apply, you get `read_only`.

On `precondition_failed`: the block changed after you read it. Read it again
with `get-block --json` and decide again on what it says now. Do not repeat
the same change with the fresh hash: that writes your old decision over the
change that was made. It differs from `block_changed`, which the command finds
itself between its own read and its write.

What a precondition does not catch: a change between the check and the write
(reading and writing are two requests), and a move (the hash sees content, not
position). A write's output carries no new hash yet, so read the block again
before changing it a second time.

`write_not_verified` can be a false alarm when someone else wrote the block
between the write and the read-back; read the block before retrying.

Expand All @@ -522,6 +540,28 @@ request; it does not close it.
`replace-text` writes the other blocks when one is refused, and names each
refused block's reason in `failed_reasons` (`{id: reason}`) beside `failed`.

### 9. Changing a Block You Read Earlier

Under `--json`, the blocks of `get-block`, `get-page`, `get-journal-range`,
`find-block` and `get-todos` carry a `hash`; `get-block` also carries a
`tree_hash` (not with `--no-children`). The text output shows neither.

When you read a block, decide, and change it later, pass what you read. The
user may have edited the block in Logseq in between, and without a
precondition the write goes through over that edit, even if the user has not
switched `require_preconditions` on. Read with `get-block --json`, then give
its `hash` as `--expect-hash` (for `set-todo-status` the marker you decided
on can be `--expect-marker`; `remove-block` takes `--expect-tree-hash`):

```bash
HASH=$(logseq-cli get-block --id 6650a1b2-0000-4000-8000-000000000001 --json | jq -r .hash)
logseq-cli update-block --id 6650a1b2-0000-4000-8000-000000000001 \
--content "TODO ship the parser, fixed" --expect-hash "$HASH"
```

On `precondition_failed` read the block again and decide again; see the
section above.

## Environment Variables

Two matter in agent work: `LOGSEQ_TOKEN`, the documented way to pass the
Expand All @@ -530,7 +570,9 @@ heading for journal writes. A config setting works like them:
`[graph] keep_empty_blocks_last` (or
`LOGSEQ_CLI_KEEP_EMPTY_BLOCKS_LAST`) makes a write that ends a section go before
the empty blocks that end it, instead of leaving one in front of the entry; it is
off by default, and nothing is overwritten. Every variable, with its default, is in
off by default, and nothing is overwritten. `LOGSEQ_CLI_READ_ONLY` and `LOGSEQ_CLI_REQUIRE_PRECONDITIONS` switch the two
`[safety]` guards on; they only tighten, and are not yours to unset. Every
variable, with its default, is in
[docs/configuration.md](docs/configuration.md#environment-variables-and-flags).

## Command Summary
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,46 @@ file carries most of the reasoning behind the tool.

### Added

- A write can say which state of a block it was decided on, and is refused when
the block is another by then. `get-block --json` gives a block a `hash` (and
`tree_hash` for the block with everything under it); `get-page`,
`get-journal-range`, `find-block` and `get-todos` give each block a `hash`
under `--json`. The write commands take it back: `--expect-hash` on
`set-todo-status`, `update-block`, `set-block-property`, `remove-property
--id`, `move-block`, `copy-block --remove` and `add-journal-block
--upsert-heading`, `--expect-tree-hash` on `remove-block` (and its alias
`delete-block`), and `--expect-marker` on `set-todo-status`. A block that is
not what was read is refused with `precondition_failed`, before `--dry-run`
and before anything is written; the refusal never carries the current hash,
since the next step is to read the block and decide again, not to repeat the
change with a hash that now matches (`examples/safe-update-block.sh`). It is
not the check `block_changed` makes: that one compares a command with its own
read, this one the caller with its read, minutes earlier.
- The hash is 12 hex characters over the block's text without collapsing,
`id::` and the clock lines of the logbook, so folding a block or a running
clock leaves it alone while an edit, a marker change or a completion moves
it. It sees content, not position: `move-block` does not notice that the
block was moved since. `remove-block` takes the hash of the whole subtree,
because the hash of the block alone would not see a child added after the
read.
- `[safety] require_preconditions = true` (also `LOGSEQ_CLI_REQUIRE_PRECONDITIONS`
and `--require-preconditions`; all three only tighten, like `read_only`)
makes the option mandatory: a call that changes a block it read and passes
none is refused with `precondition_required` before its first request.
`add-journal-block --upsert-heading` owes it only when a block matches;
creating one stays free. It is off by default: calls without a hash would
start to refuse, and since a write's output carries no new hash yet, a
second write to the same block needs a fresh read in between, which protects
no more than `block_changed`. `doctor` shows it.
- Cost: one `hash` line per block in the output, about 30 bytes. Measured on
the HTTP test double with invented blocks: 100 short blocks add 3,000
characters to indented `get-page --json`; the relative growth depends on
how large the rest of each block is (7% for the double's block shape, with
no children and no properties).
- Not covered, and said so in [docs/safety.md](docs/safety.md): there is no
compare-and-swap (a change between the check and the write is not caught),
the hash does not see position, and write outputs carry no new hash.

- `get-todos --page-type journal|page` keeps the tasks whose block stands on a
journal page, or on any other page (#118). Before, the only way to split the
two was `jq` on `journal_day`, which leaves `count` at the unfiltered value and
Expand Down
7 changes: 6 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ logseq-cli/
│ ├── outlinetext.py # Indented outline text to a block tree, and back
│ ├── output.py # Results on stdout, failures on stderr, --json
│ ├── pagenames.py # Which page a name means (an alias as Logseq resolves it), and the name Logseq creates a page under
│ ├── preconditions.py # Does the block still match what the caller read: --expect-hash, --expect-tree-hash, --expect-marker
│ ├── render.py # Blocks to text; finding and resolving references
│ ├── safety.py # What keeps a command from writing: [safety] read_only, the gate every writing command carries
│ ├── safety.py # What keeps a command from writing: [safety] read_only and require_preconditions, the gates every writing command carries
│ ├── strictinsert.py # Strict Insert: trees and --keep-ids writes land where sent; whether a move is possible; which empty block a write at the end goes before
│ ├── tasks.py # What a task is: the eleven markers, their states, reading and swapping a marker
│ ├── writerefused.py # The ways a write ends without being done or proven, each with its reason
Expand Down Expand Up @@ -76,6 +77,10 @@ logseq-cli/
if it writes: that mark is what makes the command refuse under
`[safety] read_only`. `tests/test_read_only.py` fails by comparing the
commands that carry it with the commands that write
- A command that changes a block it read also declares `owes=` on that
mark, naming the `--expect-*` options it takes.
`tests/test_precondition_coverage.py` finds such commands by those
options and fails when one of them skips the check

A Logseq method the client has not sent before goes into `_METHODS` in
`api.py` first, a write with its editor rule and its proof: `call()`
Expand Down
23 changes: 23 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,29 @@ refused. It exists because Logseq answers some failed writes with HTTP 200 and
a `null` body, so a write is verified after the fact rather than trusted.
_Avoid_: safe write, verified write

**Precondition**:
What a caller says it read before it changes a Block: the Hash (or
Tree Hash) of the block, or for `set-todo-status` its marker. The write is
refused, with `precondition_failed`, unless the block is still what the caller
read. It guards the caller's decision across calls, which Strict Insert (was
my write stored) and the `block_changed` re-read (did the block change between
this command's own read and its write) do not: only the caller knows what it
read. `[safety] require_preconditions` makes a Precondition mandatory.
_Avoid_: lock, guard, version check

**Hash**:
A short digest of one Block's content, shown as `hash` by the read commands
with `--json`. It sees text, properties and the DONE lines of the logbook; it
ignores collapsing, `id::` lines, clock lines and where the block stands, so
moving a block does not change it. Compared by value, never ordered.
_Avoid_: version, etag, checksum

**Tree Hash**:
The Hash of a Block together with everything under it, content and order
(`tree_hash` of `get-block --json`). The Precondition of `remove-block`, since
the hash of the block alone would not see a child added after the read.
_Avoid_: subtree hash, deep hash

**Dry Run**:
A preview of a write that performs no write. Available on every Command that
writes.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ files would not.
| report a write that did not land as done | never; every write is proven |
| show a change before making it | does it: `--dry-run` on every command that writes (not under `read_only`, below) |
| write when it was told to only read | refuses: `[safety] read_only = true` switches every write off, before the first request |
| change a block you edited after your agent read it | refuses when the agent passes what it read (`--expect-hash`); `[safety] require_preconditions` makes that mandatory, see [docs/safety.md](docs/safety.md#requiring-a-precondition) |
| undo a change | cannot; a deletion is final, so preview first |
| write while Logseq is closed | cannot; your agent is told to stop rather than edit the files |
| read more than fits in its context | cuts page and journal reads and searches to a size it asks for, and says what it left out |
Expand Down Expand Up @@ -127,6 +128,12 @@ itself, and one that can write files can edit the config or the Markdown files.
(config file, environment variable, `--read-only`), how to keep the config file
out of an agent's reach, and what to check when it seems not to apply.

To have the agent say what it read before it changes a block, add
`require_preconditions = true` to the same section. A command that changes a
block it read then refuses without `--expect-hash` (`--expect-tree-hash` for
`remove-block`), and `doctor` shows the switch like `read_only`; see
[Requiring a precondition](docs/safety.md#requiring-a-precondition).

### Bounded output

Reads grow with the graph. On a real one (nearly four years of daily entries)
Expand Down Expand Up @@ -241,7 +248,7 @@ non-zero rather than returning an empty result. See
[docs/configuration.md](docs/configuration.md) for every option, what happens
without it, and how to read the right values out of your own graph.

The same file can switch writes off for an agent (`[safety] read_only`, see
The same file can switch writes off for an agent (`[safety] read_only` and `require_preconditions`, see
[Limiting what an agent can do](#limiting-what-an-agent-can-do)), and keep the
empty blocks you leave at the end of a section last, so entries land before them
and no empty line is left (`[graph] keep_empty_blocks_last`, off by default, see
Expand Down
8 changes: 8 additions & 0 deletions config.example.de.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,11 @@ project_tag_prefix = "#vorhaben/"
# Keep this file out of the agent's reach, or it is only a request:
# docs/safety.md. Check that it applies with `logseq-cli doctor`.
# read_only = true

# Makes a command that changes a block it read refuse unless the call says what
# it read: --expect-hash (the hash of `get-block --json`) or --expect-marker.
# Refused with reason precondition_required, before the first request. The
# environment variable LOGSEQ_CLI_REQUIRE_PRECONDITIONS and the option
# --require-preconditions can switch it on too, but nothing switches a `true`
# here off. Turn it on when an agent writes while you edit in Logseq yourself.
# require_preconditions = true
8 changes: 8 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,11 @@ project_tag_prefix = "#projects/"
# Keep this file out of the agent's reach, or it is only a request:
# docs/safety.md. Check that it applies with `logseq-cli doctor`.
# read_only = true

# Makes a command that changes a block it read refuse unless the call says what
# it read: --expect-hash (the hash of `get-block --json`) or --expect-marker.
# Refused with reason precondition_required, before the first request. The
# environment variable LOGSEQ_CLI_REQUIRE_PRECONDITIONS and the option
# --require-preconditions can switch it on too, but nothing switches a `true`
# here off. Turn it on when an agent writes while you edit in Logseq yourself.
# require_preconditions = true
Loading
Loading